From 9df1098673628afa8589e9b1bdf20ca2dedc52e2 Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Tue, 10 Nov 2026 13:28:05 -0600 Subject: [PATCH 1/6] feat(donut): slice labels that stay inside their slice, with icons and Show modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For #870. Slice names no longer touch or cross a slice's edges, and a slice can show an icon. - Safe zone: every label stays inside its slice, inset on all four sides and clear of a group's chevron (SliceLabelLayout). Names wrap at word breaks (Chinese and Japanese by character) and are shortened with "…" only when they fit nowhere whole; the centre names the slice under the pointer in full. - One label style, one name size, one icon size and one text direction per ring (PlanRing), never a stub like "Tri…". - Show: Auto, Icon & text, Text only, Icon only, per donut (Settings > Donut toolbar) and per preset or group. - Label scale, 80-160 % per donut. Names, icons, the chevron and the centre text follow the donut's size too. - Icons: a curated set of Material Design Icons, with defaults for the built-in tools and presets; plain format conversions have none. An icon picker in the preset and group editors, with a Short name for the donut. - Strings in all 22 catalogs; ARCHITECTURE.md "Slice labels". - Tests: the layout, the editors, the picker and settings, and SliceLabelFitTests, a fit check of every built-in donut at three sizes, three Label scales, the four Show modes and every language. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 32 + src/Filee.App/Assets/i18n/ar.json | 23 + src/Filee.App/Assets/i18n/bn-BD.json | 23 + src/Filee.App/Assets/i18n/de.json | 23 + src/Filee.App/Assets/i18n/en.json | 23 + src/Filee.App/Assets/i18n/es.json | 23 + src/Filee.App/Assets/i18n/fa.json | 23 + src/Filee.App/Assets/i18n/fr.json | 23 + src/Filee.App/Assets/i18n/hi.json | 23 + src/Filee.App/Assets/i18n/id.json | 23 + src/Filee.App/Assets/i18n/it.json | 23 + src/Filee.App/Assets/i18n/ja.json | 23 + src/Filee.App/Assets/i18n/ko.json | 23 + src/Filee.App/Assets/i18n/ne.json | 23 + src/Filee.App/Assets/i18n/pl.json | 23 + src/Filee.App/Assets/i18n/pt-BR.json | 23 + src/Filee.App/Assets/i18n/pt-PT.json | 23 + src/Filee.App/Assets/i18n/ru.json | 23 + src/Filee.App/Assets/i18n/th.json | 23 + src/Filee.App/Assets/i18n/tr.json | 23 + src/Filee.App/Assets/i18n/vi.json | 23 + src/Filee.App/Assets/i18n/zh-CN.json | 23 + src/Filee.App/Assets/i18n/zh-TW.json | 23 + src/Filee.App/Controls/DonutMenu.Flip.cs | 4 + src/Filee.App/Controls/DonutMenu.Groups.cs | 13 +- src/Filee.App/Controls/DonutMenu.Progress.cs | 4 +- src/Filee.App/Controls/DonutMenu.cs | 254 ++++--- src/Filee.App/Controls/SliceIcons.Data.cs | 204 ++++++ src/Filee.App/Controls/SliceIcons.cs | 97 +++ src/Filee.App/Controls/SliceLabelLayout.cs | 624 ++++++++++++++++++ .../ViewModels/GroupEditorViewModel.cs | 7 + .../ViewModels/IconPickerViewModel.cs | 111 ++++ .../ViewModels/Pages/ToolbarPageViewModel.cs | 72 +- .../ViewModels/PresetEditorViewModel.cs | 7 + src/Filee.App/ViewModels/RadialViewModel.cs | 23 +- src/Filee.App/ViewModels/SliceLabelFields.cs | 86 +++ src/Filee.App/Views/GroupEditorWindow.axaml | 2 + src/Filee.App/Views/IconPickerFlyout.axaml | 67 ++ src/Filee.App/Views/IconPickerFlyout.axaml.cs | 109 +++ src/Filee.App/Views/Pages/ToolbarPage.axaml | 19 +- src/Filee.App/Views/PresetEditorView.axaml | 4 + src/Filee.App/Views/RadialWindow.axaml | 2 + .../Views/SliceLabelFieldsView.axaml | 40 ++ .../Views/SliceLabelFieldsView.axaml.cs | 9 + src/Filee.Core/Localization/ILocalizer.cs | 11 +- src/Filee.Core/Presets/LabelShow.cs | 60 ++ src/Filee.Core/Presets/Preset.cs | 19 + src/Filee.Core/Profiles/ToolbarProfile.cs | 23 + src/Filee.Core/Settings/UserDataStore.cs | 2 + .../DonutLabelPlacementTests.cs | 51 +- tests/Filee.App.Tests/IconPickerTests.cs | 114 ++++ tests/Filee.App.Tests/RadialLabelTests.cs | 112 ++++ tests/Filee.App.Tests/SliceIconsTests.cs | 97 +++ .../Filee.App.Tests/SliceLabelEditorTests.cs | 128 ++++ tests/Filee.App.Tests/SliceLabelFitTests.cs | 157 +++++ .../Filee.App.Tests/SliceLabelLayoutTests.cs | 481 ++++++++++++++ .../ToolbarLabelSettingsTests.cs | 88 +++ .../SliceLabelSettingsTests.cs | 131 ++++ 58 files changed, 3644 insertions(+), 126 deletions(-) create mode 100644 src/Filee.App/Controls/SliceIcons.Data.cs create mode 100644 src/Filee.App/Controls/SliceIcons.cs create mode 100644 src/Filee.App/Controls/SliceLabelLayout.cs create mode 100644 src/Filee.App/ViewModels/IconPickerViewModel.cs create mode 100644 src/Filee.App/ViewModels/SliceLabelFields.cs create mode 100644 src/Filee.App/Views/IconPickerFlyout.axaml create mode 100644 src/Filee.App/Views/IconPickerFlyout.axaml.cs create mode 100644 src/Filee.App/Views/SliceLabelFieldsView.axaml create mode 100644 src/Filee.App/Views/SliceLabelFieldsView.axaml.cs create mode 100644 src/Filee.Core/Presets/LabelShow.cs create mode 100644 tests/Filee.App.Tests/IconPickerTests.cs create mode 100644 tests/Filee.App.Tests/RadialLabelTests.cs create mode 100644 tests/Filee.App.Tests/SliceIconsTests.cs create mode 100644 tests/Filee.App.Tests/SliceLabelEditorTests.cs create mode 100644 tests/Filee.App.Tests/SliceLabelFitTests.cs create mode 100644 tests/Filee.App.Tests/SliceLabelLayoutTests.cs create mode 100644 tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs create mode 100644 tests/Filee.Core.Tests/SliceLabelSettingsTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0b7dbcde4..8a1b1b6f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -287,6 +287,38 @@ without touching the file's content. - PDFs: ExifTool appends its changes as an update, so earlier values stay recoverable inside the file; the window says so. +### Slice labels + +`Controls/SliceLabelLayout.cs` decides what each slice shows and where, without drawing; `DonutMenu.DrawSliceLabel` +draws the result. Every label stays inside the slice's *safe zone*: the slice inset by a margin of +`max(6·s, 0.08·depth)` from all four edges (plus half the 3 px gap on the sides, and clear of a group's chevron), +checked by sampling each box's outline, since the inner edge curves inward. `s = OuterRadius / 150` is the donut +scale; names start at `max(10, 13 × LabelScale × s)` px, captions 80 % of that, icons 20·k beside a name or 16–28·k +alone (k = LabelScale · s). + +What a slice shows is its preset's `Show` (`LabelShow`: Auto, IconAndText, TextOnly, IconOnly), else its donut's +`ToolbarProfile.LabelShow`, else Auto. `PlanRing` lays out a whole ring at once: one style for the slices that have an +icon, one name size and one icon size. Each style is tried at every size from the starting one down, 0.5 px at a +time, to `max(10, 11 × s)` (`SizesFor`; the icon beside a name shrinks with it, to 16·k), and taken only if every +slice fits. Auto: icon above the whole name, else the whole name alone, else icons. Icon & text: icon above the whole +name, else above a name shortened to no less than 60 % of its letters and never dropping a word (`MinKept`), else +icons alone; never a stub like "Tri…". Text only: whole names, else shortened to `MinKept`, else as far as it takes. +An icon stands in for the caption; names alone show their captions on every slice that has one or on none. A slice +without an icon (a format conversion) shows its name and doesn't decide the others' style. Names wrap at word +boundaries (CJK by character, Korean by word), one way for the ring: straight on every slice, else along every +slice (`TextWay`), each through the sizes; beside icons above names, a format's name runs straight. `DonutMenu.LabelPlan` caches each ring's plan by its inputs (items, geometry, scale, settings), +so labels are laid out once per change, not per frame; the open, pop and flip animations move them with transforms. +A group's ring doesn't follow the donut's size, so its labels don't either; they follow the Label scale as far as +its slices (about 30 px wide) have room: `PlanRingToFit` steps the scale down 5 % at a time, not below 100 %, until +every slice shows its whole name or its icon. + +The icons are a curated set of Material Design Icons (`SliceIcons.Data.cs`, generated; Apache-2.0, see +THIRD-PARTY-NOTICES.md), keyed by their MDI names and grouped for the picker. `Preset.Icon` is a key, `"none"`, or +unset for the default: a built-in preset's own (by id), else its tool's; plain format conversions have none. +`Preset.ShortName` replaces the name on the donut and its rings (`DonutName` / `RingName`); the centre hint always +names the slice in full (`DonutItem.FullName`). The preset and group editors share `SliceLabelFields` (Icon with +`IconPickerFlyout`, Short name, Show); the Toolbar page sets each donut's Slice labels and Label scale. + ## Crop overlay and PDF page editor The image Crop tool (`tool-crop`) and PDF Crop (`tool-pdf-crop`) open `Views/CropOverlayWindow` instead of running a job: diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 8e7bc3e90..43a1a441d 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -725,6 +725,12 @@ "toolbar.hole": "الفتحة الوسطى", "toolbar.opacity": "العتامة", "toolbar.captions": "إظهار صيغة الناتج", + "toolbar.slice_labels": "تسميات القطاعات", + "toolbar.label_scale": "حجم التسميات", + "labels.auto": "تلقائي", + "labels.icon_text": "أيقونة ونص", + "labels.text_only": "نص فقط", + "labels.icon_only": "أيقونة فقط", "toolbar.new_preset": "إعداد مسبق جديد", "toolbar.new_group": "مجموعة جديدة", "toolbar.face_front": "الأمام", @@ -788,6 +794,23 @@ "presets.save": "حفظ", "presets.revert": "تراجع عن التغييرات", "presets.name": "الاسم", + "presets.icon": "الأيقونة", + "presets.icon_default": "الافتراضية", + "presets.icon_none": "بلا", + "presets.icon_search": "ابحث عن أيقونة", + "presets.icon_no_match": "لا توجد أيقونة تطابق البحث.", + "presets.short_name": "اسم مختصر", + "presets.short_name_hint": "يظهر على الشريط الدائري بدل الاسم", + "presets.show": "ما يظهر على القطاع", + "presets.show_donut": "إعداد الشريط الدائري", + "icons.cat.files_and_folders": "الملفات والمجلدات", + "icons.cat.pictures": "الصور", + "icons.cat.crop_and_transform": "القص والتحويل", + "icons.cat.pdf_and_pages": "PDF والصفحات", + "icons.cat.video_and_audio": "الفيديو والصوت", + "icons.cat.text_and_data": "النص والبيانات", + "icons.cat.share_and_move": "المشاركة والنقل", + "icons.cat.general": "عام", "presets.target": "التحويل إلى", "presets.target_hidden": "الهدف الحالي ({0}) ينتمي فقط إلى مجالات معطلة، لذا لا يظهر في القائمة. سيبقى الهدف ما لم تختر هدفًا آخر.", "presets.same_as_source": "صيغة الملف الأصلي نفسها", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index 7ef0f2a12..74a4fd622 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -724,6 +724,12 @@ "toolbar.hole": "ছিদ্র", "toolbar.opacity": "অস্বচ্ছতা", "toolbar.captions": "আউটপুট ফরম্যাট দেখান", + "toolbar.slice_labels": "স্লাইসের লেবেল", + "toolbar.label_scale": "লেবেলের আকার", + "labels.auto": "স্বয়ংক্রিয়", + "labels.icon_text": "আইকন ও টেক্সট", + "labels.text_only": "কেবল টেক্সট", + "labels.icon_only": "কেবল আইকন", "toolbar.new_preset": "নতুন প্রিসেট", "toolbar.new_group": "নতুন গ্রুপ", "toolbar.face_front": "সামনে", @@ -787,6 +793,23 @@ "presets.save": "সংরক্ষণ করুন", "presets.revert": "পূর্বাবস্থায় আনুন", "presets.name": "নাম", + "presets.icon": "আইকন", + "presets.icon_default": "ডিফল্ট", + "presets.icon_none": "কোনোটি নয়", + "presets.icon_search": "আইকন অনুসন্ধান করুন", + "presets.icon_no_match": "অনুসন্ধানের সাথে কোনো আইকন মেলেনি।", + "presets.short_name": "সংক্ষিপ্ত নাম", + "presets.short_name_hint": "নামের বদলে ডোনাটে দেখানো হয়", + "presets.show": "স্লাইসে দেখান", + "presets.show_donut": "ডোনাটের সেটিং ব্যবহার করুন", + "icons.cat.files_and_folders": "ফাইল ও ফোল্ডার", + "icons.cat.pictures": "ছবি", + "icons.cat.crop_and_transform": "ক্রপ ও রূপান্তর", + "icons.cat.pdf_and_pages": "পিডিএফ ও পৃষ্ঠা", + "icons.cat.video_and_audio": "ভিডিও ও অডিও", + "icons.cat.text_and_data": "টেক্সট ও ডেটা", + "icons.cat.share_and_move": "শেয়ার ও সরানো", + "icons.cat.general": "সাধারণ", "presets.target": "এতে রূপান্তর করুন", "presets.target_hidden": "বর্তমান লক্ষ্য ({0}) কেবল বন্ধ থাকা ক্ষেত্রগুলোর অন্তর্ভুক্ত, তাই এটি তালিকায় নেই। অন্য কোনোটি নির্বাচন না করা পর্যন্ত এটিই লক্ষ্য থাকবে।", "presets.same_as_source": "উৎস ফাইলের একই ফরম্যাট", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 03cf19093..c83920050 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -725,6 +725,12 @@ "toolbar.hole": "Innenöffnung", "toolbar.opacity": "Deckkraft", "toolbar.captions": "Ausgabeformat anzeigen", + "toolbar.slice_labels": "Segmentbeschriftung", + "toolbar.label_scale": "Beschriftungsgröße", + "labels.auto": "Automatisch", + "labels.icon_text": "Symbol und Text", + "labels.text_only": "Nur Text", + "labels.icon_only": "Nur Symbol", "toolbar.new_preset": "Neue Vorlage", "toolbar.new_group": "Neue Gruppe", "toolbar.face_front": "Vorderseite", @@ -788,6 +794,23 @@ "presets.save": "Speichern", "presets.revert": "Zurücksetzen", "presets.name": "Name", + "presets.icon": "Symbol", + "presets.icon_default": "Standard", + "presets.icon_none": "Keines", + "presets.icon_search": "Symbole suchen", + "presets.icon_no_match": "Kein Symbol passt zur Suche.", + "presets.short_name": "Kurzname", + "presets.short_name_hint": "Wird im Ringmenü statt des Namens angezeigt", + "presets.show": "Im Segment zeigen", + "presets.show_donut": "Einstellung des Ringmenüs", + "icons.cat.files_and_folders": "Dateien und Ordner", + "icons.cat.pictures": "Bilder", + "icons.cat.crop_and_transform": "Zuschneiden und Drehen", + "icons.cat.pdf_and_pages": "PDF und Seiten", + "icons.cat.video_and_audio": "Video und Audio", + "icons.cat.text_and_data": "Text und Daten", + "icons.cat.share_and_move": "Teilen und Verschieben", + "icons.cat.general": "Allgemein", "presets.target": "Konvertieren in", "presets.target_hidden": "Das aktuelle Zielformat ({0}) gehört nur zu deaktivierten Bereichen und steht daher nicht in der Liste. Es bleibt ausgewählt, solange du kein anderes wählst.", "presets.same_as_source": "Gleiches Format wie die Quelldatei", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index ae36e7860..ac013b4ac 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -739,6 +739,12 @@ "toolbar.hole": "Hole", "toolbar.opacity": "Opacity", "toolbar.captions": "Show output format", + "toolbar.slice_labels": "Slice labels", + "toolbar.label_scale": "Label scale", + "labels.auto": "Auto", + "labels.icon_text": "Icon & text", + "labels.text_only": "Text only", + "labels.icon_only": "Icon only", "toolbar.new_preset": "New preset", "toolbar.new_group": "New group", "toolbar.face_front": "Front", @@ -803,6 +809,23 @@ "presets.save": "Save", "presets.revert": "Revert", "presets.name": "Name", + "presets.icon": "Icon", + "presets.icon_default": "Default", + "presets.icon_none": "None", + "presets.icon_search": "Search icons", + "presets.icon_no_match": "No icon matches the search.", + "presets.short_name": "Short name", + "presets.short_name_hint": "Shown on the donut instead of the name", + "presets.show": "Show on the slice", + "presets.show_donut": "Use the donut's setting", + "icons.cat.files_and_folders": "Files & folders", + "icons.cat.pictures": "Pictures", + "icons.cat.crop_and_transform": "Crop & transform", + "icons.cat.pdf_and_pages": "PDF & pages", + "icons.cat.video_and_audio": "Video & audio", + "icons.cat.text_and_data": "Text & data", + "icons.cat.share_and_move": "Share & move", + "icons.cat.general": "General", "presets.target": "Convert to", "presets.target_hidden": "The current target ({0}) belongs only to fields that are turned off, so it isn't in the list. It stays the target unless you choose another.", "presets.same_as_source": "Same format as the source", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 8c88423e3..e21d1ec9b 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -725,6 +725,12 @@ "toolbar.hole": "Hueco", "toolbar.opacity": "Opacidad", "toolbar.captions": "Mostrar formato de salida", + "toolbar.slice_labels": "Etiquetas de las porciones", + "toolbar.label_scale": "Tamaño de las etiquetas", + "labels.auto": "Automático", + "labels.icon_text": "Icono y texto", + "labels.text_only": "Solo texto", + "labels.icon_only": "Solo icono", "toolbar.new_preset": "Nuevo ajuste", "toolbar.new_group": "Nuevo grupo", "toolbar.face_front": "Anverso", @@ -788,6 +794,23 @@ "presets.save": "Guardar", "presets.revert": "Revertir", "presets.name": "Nombre", + "presets.icon": "Icono", + "presets.icon_default": "Predeterminado", + "presets.icon_none": "Ninguno", + "presets.icon_search": "Buscar iconos", + "presets.icon_no_match": "Ningún icono coincide con la búsqueda.", + "presets.short_name": "Nombre corto", + "presets.short_name_hint": "Se muestra en el menú circular en lugar del nombre", + "presets.show": "Mostrar en la porción", + "presets.show_donut": "Usar el ajuste del menú circular", + "icons.cat.files_and_folders": "Archivos y carpetas", + "icons.cat.pictures": "Imágenes", + "icons.cat.crop_and_transform": "Recortar y transformar", + "icons.cat.pdf_and_pages": "PDF y páginas", + "icons.cat.video_and_audio": "Vídeo y audio", + "icons.cat.text_and_data": "Texto y datos", + "icons.cat.share_and_move": "Compartir y mover", + "icons.cat.general": "General", "presets.target": "Convertir a", "presets.target_hidden": "El destino actual ({0}) solo pertenece a áreas desactivadas, por lo que no aparece en la lista. Seguirá siendo el destino salvo que elijas otro.", "presets.same_as_source": "El mismo formato que el original", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index 8c3f722c0..73b0a7ee3 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -724,6 +724,12 @@ "toolbar.hole": "حفره میانی", "toolbar.opacity": "شفافیت", "toolbar.captions": "نمایش فرمت خروجی", + "toolbar.slice_labels": "برچسب بخش‌ها", + "toolbar.label_scale": "اندازه برچسب‌ها", + "labels.auto": "خودکار", + "labels.icon_text": "آیکون و متن", + "labels.text_only": "فقط متن", + "labels.icon_only": "فقط آیکون", "toolbar.new_preset": "پیش‌تنظیم جدید", "toolbar.new_group": "گروه جدید", "toolbar.face_front": "رو", @@ -787,6 +793,23 @@ "presets.save": "ذخیره", "presets.revert": "بازگردانی", "presets.name": "نام", + "presets.icon": "آیکون", + "presets.icon_default": "پیش‌فرض", + "presets.icon_none": "هیچ‌کدام", + "presets.icon_search": "جستجوی آیکون‌ها", + "presets.icon_no_match": "هیچ آیکونی با جستجو مطابقت ندارد.", + "presets.short_name": "نام کوتاه", + "presets.short_name_hint": "به‌جای نام روی دونات نمایش داده می‌شود", + "presets.show": "نمایش روی بخش", + "presets.show_donut": "استفاده از تنظیم دونات", + "icons.cat.files_and_folders": "فایل‌ها و پوشه‌ها", + "icons.cat.pictures": "تصاویر", + "icons.cat.crop_and_transform": "برش و تبدیل شکل", + "icons.cat.pdf_and_pages": "PDF و صفحات", + "icons.cat.video_and_audio": "ویدیو و صدا", + "icons.cat.text_and_data": "متن و داده", + "icons.cat.share_and_move": "اشتراک‌گذاری و جابه‌جایی", + "icons.cat.general": "عمومی", "presets.target": "تبدیل به", "presets.target_hidden": "مقصد فعلی ({0}) فقط متعلق به حوزه‌هایی است که خاموش شده‌اند، بنابراین در لیست نیست. این فرمت مقصد باقی می‌ماند مگر اینکه فرمت دیگری انتخاب کنید.", "presets.same_as_source": "همان فرمت فایل مبدأ", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index fc7c5b557..f52bc682d 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -739,6 +739,12 @@ "toolbar.hole": "Trou", "toolbar.opacity": "Opacité", "toolbar.captions": "Afficher le format de sortie", + "toolbar.slice_labels": "Libellés des tranches", + "toolbar.label_scale": "Taille des libellés", + "labels.auto": "Auto", + "labels.icon_text": "Icône et texte", + "labels.text_only": "Texte seul", + "labels.icon_only": "Icône seule", "toolbar.new_preset": "Préréglage", "toolbar.new_group": "Groupe", "toolbar.face_front": "Recto", @@ -803,6 +809,23 @@ "presets.save": "Enregistrer", "presets.revert": "Rétablir", "presets.name": "Nom", + "presets.icon": "Icône", + "presets.icon_default": "Par défaut", + "presets.icon_none": "Aucune", + "presets.icon_search": "Rechercher une icône", + "presets.icon_no_match": "Aucune icône ne correspond à la recherche.", + "presets.short_name": "Nom court", + "presets.short_name_hint": "Affiché sur le menu circulaire à la place du nom", + "presets.show": "Afficher sur la tranche", + "presets.show_donut": "Réglage du menu circulaire", + "icons.cat.files_and_folders": "Fichiers et dossiers", + "icons.cat.pictures": "Images", + "icons.cat.crop_and_transform": "Recadrer et transformer", + "icons.cat.pdf_and_pages": "PDF et pages", + "icons.cat.video_and_audio": "Vidéo et audio", + "icons.cat.text_and_data": "Texte et données", + "icons.cat.share_and_move": "Partager et déplacer", + "icons.cat.general": "Général", "presets.target": "Convertir en", "presets.target_hidden": "La cible actuelle ({0}) appartient uniquement à des domaines désactivés et ne figure donc pas dans la liste. Elle reste la cible tant que vous n'en choisissez pas une autre.", "presets.same_as_source": "Même format que l'original", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 65681c0d8..b14ff7d8f 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -725,6 +725,12 @@ "toolbar.hole": "छेद", "toolbar.opacity": "अपारदर्शिता", "toolbar.captions": "आउटपुट फ़ॉर्मैट दिखाएँ", + "toolbar.slice_labels": "स्लाइस लेबल", + "toolbar.label_scale": "लेबल का आकार", + "labels.auto": "अपने आप", + "labels.icon_text": "आइकन और टेक्स्ट", + "labels.text_only": "सिर्फ़ टेक्स्ट", + "labels.icon_only": "सिर्फ़ आइकन", "toolbar.new_preset": "नया प्रीसेट", "toolbar.new_group": "नया समूह", "toolbar.face_front": "आगे", @@ -788,6 +794,23 @@ "presets.save": "सहेजें", "presets.revert": "वापस लौटाएँ", "presets.name": "नाम", + "presets.icon": "आइकन", + "presets.icon_default": "डिफ़ॉल्ट", + "presets.icon_none": "कोई नहीं", + "presets.icon_search": "आइकन खोजें", + "presets.icon_no_match": "खोज से कोई आइकन मेल नहीं खाता।", + "presets.short_name": "छोटा नाम", + "presets.short_name_hint": "डोनट पर नाम की जगह दिखता है", + "presets.show": "स्लाइस पर दिखाएँ", + "presets.show_donut": "डोनट की सेटिंग", + "icons.cat.files_and_folders": "फ़ाइलें और फ़ोल्डर", + "icons.cat.pictures": "तस्वीरें", + "icons.cat.crop_and_transform": "क्रॉप और बदलाव", + "icons.cat.pdf_and_pages": "PDF और पेज", + "icons.cat.video_and_audio": "वीडियो और ऑडियो", + "icons.cat.text_and_data": "टेक्स्ट और डेटा", + "icons.cat.share_and_move": "शेयर और स्थानांतरण", + "icons.cat.general": "सामान्य", "presets.target": "इसमें कन्वर्ट करें", "presets.target_hidden": "मौजूदा लक्ष्य ({0}) केवल बंद क्षेत्रों से जुड़ा है, इसलिए सूची में नहीं है। दूसरा लक्ष्य चुनने तक यही लक्ष्य रहेगा।", "presets.same_as_source": "स्रोत जैसा ही फ़ॉर्मैट", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index a7be6ef92..77ea76d02 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -725,6 +725,12 @@ "toolbar.hole": "Lubang tengah", "toolbar.opacity": "Transparansi", "toolbar.captions": "Tampilkan format keluaran", + "toolbar.slice_labels": "Label irisan", + "toolbar.label_scale": "Skala label", + "labels.auto": "Otomatis", + "labels.icon_text": "Ikon & teks", + "labels.text_only": "Hanya teks", + "labels.icon_only": "Hanya ikon", "toolbar.new_preset": "Preset baru", "toolbar.new_group": "Grup baru", "toolbar.face_front": "Depan", @@ -788,6 +794,23 @@ "presets.save": "Simpan", "presets.revert": "Kembalikan", "presets.name": "Nama", + "presets.icon": "Ikon", + "presets.icon_default": "Bawaan", + "presets.icon_none": "Tidak ada", + "presets.icon_search": "Cari ikon", + "presets.icon_no_match": "Tidak ada ikon yang cocok dengan pencarian.", + "presets.short_name": "Nama pendek", + "presets.short_name_hint": "Ditampilkan di donut sebagai ganti nama", + "presets.show": "Tampilkan di irisan", + "presets.show_donut": "Gunakan pengaturan donut", + "icons.cat.files_and_folders": "File & folder", + "icons.cat.pictures": "Gambar", + "icons.cat.crop_and_transform": "Pangkas & ubah bentuk", + "icons.cat.pdf_and_pages": "PDF & halaman", + "icons.cat.video_and_audio": "Video & audio", + "icons.cat.text_and_data": "Teks & data", + "icons.cat.share_and_move": "Bagikan & pindahkan", + "icons.cat.general": "Umum", "presets.target": "Konversi ke", "presets.target_hidden": "Target saat ini ({0}) hanya termasuk dalam bidang yang dinonaktifkan, sehingga tidak ada dalam daftar. Target ini tetap dipilih kecuali Anda memilih yang lain.", "presets.same_as_source": "Format sama seperti sumber", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 3013b6abb..1ec447732 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -724,6 +724,12 @@ "toolbar.hole": "Foro", "toolbar.opacity": "Opacità", "toolbar.captions": "Mostra formato di output", +"toolbar.slice_labels": "Etichette degli spicchi", +"toolbar.label_scale": "Dimensione etichette", +"labels.auto": "Auto", +"labels.icon_text": "Icona e testo", +"labels.text_only": "Solo testo", +"labels.icon_only": "Solo icona", "toolbar.new_preset": "Nuovo preset", "toolbar.new_group": "Nuovo gruppo", "toolbar.face_front": "Fronte", @@ -787,6 +793,23 @@ "presets.save": "Salva", "presets.revert": "Annulla modifiche", "presets.name": "Nome", +"presets.icon": "Icona", +"presets.icon_default": "Predefinita", +"presets.icon_none": "Nessuna", +"presets.icon_search": "Cerca icone", +"presets.icon_no_match": "Nessuna icona corrisponde alla ricerca.", +"presets.short_name": "Nome breve", +"presets.short_name_hint": "Mostrato sul donut al posto del nome", +"presets.show": "Mostra sullo spicchio", +"presets.show_donut": "Usa l’impostazione del donut", +"icons.cat.files_and_folders": "File e cartelle", +"icons.cat.pictures": "Immagini", +"icons.cat.crop_and_transform": "Ritaglia e trasforma", +"icons.cat.pdf_and_pages": "PDF e pagine", +"icons.cat.video_and_audio": "Video e audio", +"icons.cat.text_and_data": "Testo e dati", +"icons.cat.share_and_move": "Condividi e sposta", +"icons.cat.general": "Generale", "presets.target": "Converti in", "presets.target_hidden": "Il formato di destinazione attuale ({0}) appartiene solo a settori disattivati, quindi non è nell'elenco. Rimane selezionato finché non ne scegli un altro.", "presets.same_as_source": "Stesso formato dell'origine", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index fdb7628ee..b01705812 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -725,6 +725,12 @@ "toolbar.hole": "中央の穴", "toolbar.opacity": "不透明度", "toolbar.captions": "出力形式を表示", + "toolbar.slice_labels": "項目のラベル", + "toolbar.label_scale": "ラベルの大きさ", + "labels.auto": "自動", + "labels.icon_text": "アイコンとテキスト", + "labels.text_only": "テキストのみ", + "labels.icon_only": "アイコンのみ", "toolbar.new_preset": "新規作成", "toolbar.new_group": "グループ作成", "toolbar.face_front": "表面", @@ -788,6 +794,23 @@ "presets.save": "保存", "presets.revert": "元に戻す", "presets.name": "名前", + "presets.icon": "アイコン", + "presets.icon_default": "既定", + "presets.icon_none": "なし", + "presets.icon_search": "アイコンを検索", + "presets.icon_no_match": "一致するアイコンはありません。", + "presets.short_name": "短い名前", + "presets.short_name_hint": "ドーナツメニューで名前の代わりに表示", + "presets.show": "項目に表示", + "presets.show_donut": "ドーナツメニューの設定に従う", + "icons.cat.files_and_folders": "ファイルとフォルダー", + "icons.cat.pictures": "画像", + "icons.cat.crop_and_transform": "切り抜きと変形", + "icons.cat.pdf_and_pages": "PDF とページ", + "icons.cat.video_and_audio": "動画と音声", + "icons.cat.text_and_data": "テキストとデータ", + "icons.cat.share_and_move": "共有と移動", + "icons.cat.general": "一般", "presets.target": "変換先", "presets.target_hidden": "現在の変換先({0})はオフの分野にのみ属しているため、一覧に表示されません。別の変換先を選ばない限り、そのまま保持されます。", "presets.same_as_source": "元ファイルと同じ形式", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 7da48045f..36c5a22a1 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -739,6 +739,12 @@ "toolbar.hole": "구멍", "toolbar.opacity": "불투명도", "toolbar.captions": "출력 형식 표시", + "toolbar.slice_labels": "세그먼트 레이블", + "toolbar.label_scale": "레이블 크기", + "labels.auto": "자동", + "labels.icon_text": "아이콘과 텍스트", + "labels.text_only": "텍스트만", + "labels.icon_only": "아이콘만", "toolbar.new_preset": "새 프리셋", "toolbar.new_group": "새 묶음", "toolbar.face_front": "앞면", @@ -803,6 +809,23 @@ "presets.save": "저장", "presets.revert": "되돌리기", "presets.name": "이름", + "presets.icon": "아이콘", + "presets.icon_default": "기본값", + "presets.icon_none": "없음", + "presets.icon_search": "아이콘 검색", + "presets.icon_no_match": "검색과 일치하는 아이콘이 없습니다.", + "presets.short_name": "짧은 이름", + "presets.short_name_hint": "도넛에 이름 대신 표시", + "presets.show": "세그먼트에 표시", + "presets.show_donut": "도넛 설정 따르기", + "icons.cat.files_and_folders": "파일 및 폴더", + "icons.cat.pictures": "그림", + "icons.cat.crop_and_transform": "자르기 및 변형", + "icons.cat.pdf_and_pages": "PDF 및 페이지", + "icons.cat.video_and_audio": "동영상 및 오디오", + "icons.cat.text_and_data": "텍스트 및 데이터", + "icons.cat.share_and_move": "공유 및 이동", + "icons.cat.general": "일반", "presets.target": "변환 형식", "presets.target_hidden": "현재 변환 형식({0})은 꺼진 분야에만 속해서 목록에 없어요. 다른 형식을 고르지 않으면 그대로 저장돼요.", "presets.same_as_source": "원본과 같은 형식", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index a4380c3d4..d0a3a97f0 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -725,6 +725,12 @@ "toolbar.hole": "प्वाल (केन्द्र)", "toolbar.opacity": "अपारदर्शिता", "toolbar.captions": "आउटपुट ढाँचा देखाउनुहोस्", + "toolbar.slice_labels": "स्लाइस लेबल", + "toolbar.label_scale": "लेबलको आकार", + "labels.auto": "स्वचालित", + "labels.icon_text": "आइकन र पाठ", + "labels.text_only": "पाठ मात्र", + "labels.icon_only": "आइकन मात्र", "toolbar.new_preset": "नयाँ प्रिसेट", "toolbar.new_group": "नयाँ समूह", "toolbar.face_front": "अगाडि", @@ -788,6 +794,23 @@ "presets.save": "सुरक्षित गर्नुहोस्", "presets.revert": "पहिलेको जस्तै बनाउनुहोस्", "presets.name": "नाम", + "presets.icon": "आइकन", + "presets.icon_default": "पूर्वनिर्धारित", + "presets.icon_none": "कुनै पनि होइन", + "presets.icon_search": "आइकन खोज्नुहोस्", + "presets.icon_no_match": "खोजसँग मिल्ने आइकन छैन।", + "presets.short_name": "छोटो नाम", + "presets.short_name_hint": "डोनटमा नामको सट्टा देखिन्छ", + "presets.show": "स्लाइसमा देखाउनुहोस्", + "presets.show_donut": "डोनटको सेटिङ", + "icons.cat.files_and_folders": "फाइल र फोल्डर", + "icons.cat.pictures": "तस्बिरहरू", + "icons.cat.crop_and_transform": "क्रप र रूपान्तरण", + "icons.cat.pdf_and_pages": "PDF र पृष्ठहरू", + "icons.cat.video_and_audio": "भिडियो र अडियो", + "icons.cat.text_and_data": "पाठ र डेटा", + "icons.cat.share_and_move": "साझा र सार्ने", + "icons.cat.general": "सामान्य", "presets.target": "यसमा रूपान्तरण गर्नुहोस्", "presets.target_hidden": "हालको लक्ष्य ({0}) बन्द गरिएका क्षेत्रहरूमा मात्र पर्छ, त्यसैले सूचीमा छैन। अर्को लक्ष्य नछान्दासम्म यही लक्ष्य रहन्छ।", "presets.same_as_source": "स्रोत जस्तै उही ढाँचा", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index d51228ccb..3382ba772 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -739,6 +739,12 @@ "toolbar.hole": "Otwór", "toolbar.opacity": "Krycie", "toolbar.captions": "Pokazuj format wyjściowy", + "toolbar.slice_labels": "Etykiety wycinków", + "toolbar.label_scale": "Rozmiar etykiet", + "labels.auto": "Automatycznie", + "labels.icon_text": "Ikona i tekst", + "labels.text_only": "Tylko tekst", + "labels.icon_only": "Tylko ikona", "toolbar.new_preset": "Nowe ustawienie", "toolbar.new_group": "Nowa grupa", "toolbar.face_front": "Przód", @@ -803,6 +809,23 @@ "presets.save": "Zapisz", "presets.revert": "Cofnij", "presets.name": "Nazwa", + "presets.icon": "Ikona", + "presets.icon_default": "Domyślna", + "presets.icon_none": "Brak", + "presets.icon_search": "Szukaj ikon", + "presets.icon_no_match": "Żadna ikona nie pasuje do wyszukiwania.", + "presets.short_name": "Krótka nazwa", + "presets.short_name_hint": "Widoczna na pierścieniu zamiast nazwy", + "presets.show": "Pokaż na wycinku", + "presets.show_donut": "Ustawienie pierścienia", + "icons.cat.files_and_folders": "Pliki i foldery", + "icons.cat.pictures": "Obrazy", + "icons.cat.crop_and_transform": "Kadrowanie i obrót", + "icons.cat.pdf_and_pages": "PDF i strony", + "icons.cat.video_and_audio": "Wideo i dźwięk", + "icons.cat.text_and_data": "Tekst i dane", + "icons.cat.share_and_move": "Udostępnianie i przenoszenie", + "icons.cat.general": "Ogólne", "presets.target": "Konwertuj do", "presets.target_hidden": "Bieżący format docelowy ({0}) należy tylko do wyłączonych dziedzin, dlatego nie ma go na liście. Pozostanie wybrany, dopóki nie wybierzesz innego.", "presets.same_as_source": "Ten sam format co źródło", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index da83f52e9..202b6fc5f 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -739,6 +739,12 @@ "toolbar.hole": "Furo", "toolbar.opacity": "Opacidade", "toolbar.captions": "Mostrar formato de saída", + "toolbar.slice_labels": "Rótulos das fatias", + "toolbar.label_scale": "Tamanho dos rótulos", + "labels.auto": "Automático", + "labels.icon_text": "Ícone e texto", + "labels.text_only": "Só texto", + "labels.icon_only": "Só ícone", "toolbar.new_preset": "Nova predefinição", "toolbar.new_group": "Novo grupo", "toolbar.face_front": "Frente", @@ -803,6 +809,23 @@ "presets.save": "Salvar", "presets.revert": "Desfazer", "presets.name": "Nome", + "presets.icon": "Ícone", + "presets.icon_default": "Padrão", + "presets.icon_none": "Nenhum", + "presets.icon_search": "Pesquisar ícones", + "presets.icon_no_match": "Nenhum ícone corresponde à pesquisa.", + "presets.short_name": "Nome curto", + "presets.short_name_hint": "Aparece no menu circular no lugar do nome", + "presets.show": "Mostrar na fatia", + "presets.show_donut": "Usar a configuração do menu", + "icons.cat.files_and_folders": "Arquivos e pastas", + "icons.cat.pictures": "Imagens", + "icons.cat.crop_and_transform": "Cortar e transformar", + "icons.cat.pdf_and_pages": "PDF e páginas", + "icons.cat.video_and_audio": "Vídeo e áudio", + "icons.cat.text_and_data": "Texto e dados", + "icons.cat.share_and_move": "Compartilhar e mover", + "icons.cat.general": "Geral", "presets.target": "Converter para", "presets.target_hidden": "O destino atual ({0}) pertence apenas a áreas desativadas e, por isso, não aparece na lista. Ele continuará selecionado até você escolher outro.", "presets.same_as_source": "O mesmo formato da origem", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 6d49196d6..f8735c973 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -739,6 +739,12 @@ "toolbar.hole": "Abertura central", "toolbar.opacity": "Opacidade", "toolbar.captions": "Mostrar formato de saída", + "toolbar.slice_labels": "Rótulos dos segmentos", + "toolbar.label_scale": "Tamanho dos rótulos", + "labels.auto": "Automático", + "labels.icon_text": "Ícone e texto", + "labels.text_only": "Só texto", + "labels.icon_only": "Só ícone", "toolbar.new_preset": "Nova predefinição", "toolbar.new_group": "Novo grupo", "toolbar.face_front": "Frente", @@ -803,6 +809,23 @@ "presets.save": "Guardar", "presets.revert": "Reverter", "presets.name": "Nome", + "presets.icon": "Ícone", + "presets.icon_default": "Predefinido", + "presets.icon_none": "Nenhum", + "presets.icon_search": "Pesquisar ícones", + "presets.icon_no_match": "Nenhum ícone corresponde à pesquisa.", + "presets.short_name": "Nome curto", + "presets.short_name_hint": "Aparece na barra circular em vez do nome", + "presets.show": "Mostrar no segmento", + "presets.show_donut": "Usar a definição da barra", + "icons.cat.files_and_folders": "Ficheiros e pastas", + "icons.cat.pictures": "Imagens", + "icons.cat.crop_and_transform": "Recortar e transformar", + "icons.cat.pdf_and_pages": "PDF e páginas", + "icons.cat.video_and_audio": "Vídeo e áudio", + "icons.cat.text_and_data": "Texto e dados", + "icons.cat.share_and_move": "Partilhar e mover", + "icons.cat.general": "Geral", "presets.target": "Converter para", "presets.target_hidden": "O destino atual ({0}) pertence apenas a áreas desativadas e, por isso, não aparece na lista. Continua selecionado até escolheres outro.", "presets.same_as_source": "Mesmo formato da origem", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index b1833fea5..54fbc896c 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -739,6 +739,12 @@ "toolbar.hole": "Отверстие", "toolbar.opacity": "Непрозрачность", "toolbar.captions": "Показывать формат результата", + "toolbar.slice_labels": "Подписи секторов", + "toolbar.label_scale": "Размер подписей", + "labels.auto": "Авто", + "labels.icon_text": "Значок и текст", + "labels.text_only": "Только текст", + "labels.icon_only": "Только значок", "toolbar.new_preset": "Новый пресет", "toolbar.new_group": "Новая группа", "toolbar.face_front": "Лицевая", @@ -803,6 +809,23 @@ "presets.save": "Сохранить", "presets.revert": "Отменить изменения", "presets.name": "Название", + "presets.icon": "Значок", + "presets.icon_default": "По умолчанию", + "presets.icon_none": "Нет", + "presets.icon_search": "Поиск значков", + "presets.icon_no_match": "Нет значков по этому запросу.", + "presets.short_name": "Короткое название", + "presets.short_name_hint": "Показывается в круговом меню вместо названия", + "presets.show": "Показывать в секторе", + "presets.show_donut": "Как в настройках меню", + "icons.cat.files_and_folders": "Файлы и папки", + "icons.cat.pictures": "Изображения", + "icons.cat.crop_and_transform": "Кадрирование и поворот", + "icons.cat.pdf_and_pages": "PDF и страницы", + "icons.cat.video_and_audio": "Видео и аудио", + "icons.cat.text_and_data": "Текст и данные", + "icons.cat.share_and_move": "Отправка и перемещение", + "icons.cat.general": "Общие", "presets.target": "Конвертировать в", "presets.target_hidden": "Текущий формат назначения ({0}) относится только к отключённым областям, поэтому его нет в списке. Он останется выбранным, пока вы не выберете другой.", "presets.same_as_source": "Тот же формат, что у исходника", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 07bc23d50..8f9e180ed 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -725,6 +725,12 @@ "toolbar.hole": "ขนาดรูกลาง", "toolbar.opacity": "ความทึบแสง", "toolbar.captions": "แสดงฟอร์แมตผลลัพธ์", + "toolbar.slice_labels": "ป้ายชิ้นส่วน", + "toolbar.label_scale": "ขนาดป้าย", + "labels.auto": "อัตโนมัติ", + "labels.icon_text": "ไอคอนและข้อความ", + "labels.text_only": "ข้อความเท่านั้น", + "labels.icon_only": "ไอคอนเท่านั้น", "toolbar.new_preset": "พรีเซ็ตใหม่", "toolbar.new_group": "กลุ่มใหม่", "toolbar.face_front": "ด้านหน้า", @@ -788,6 +794,23 @@ "presets.save": "บันทึก", "presets.revert": "คืนค่าเดิม", "presets.name": "ชื่อ", + "presets.icon": "ไอคอน", + "presets.icon_default": "ค่าเริ่มต้น", + "presets.icon_none": "ไม่มี", + "presets.icon_search": "ค้นหาไอคอน", + "presets.icon_no_match": "ไม่มีไอคอนที่ตรงกับคำค้นหา", + "presets.short_name": "ชื่อย่อ", + "presets.short_name_hint": "แสดงบนโดนัทแทนชื่อ", + "presets.show": "แสดงบนชิ้นส่วน", + "presets.show_donut": "ใช้การตั้งค่าของโดนัท", + "icons.cat.files_and_folders": "ไฟล์และโฟลเดอร์", + "icons.cat.pictures": "รูปภาพ", + "icons.cat.crop_and_transform": "ครอบตัดและแปลงรูป", + "icons.cat.pdf_and_pages": "PDF และหน้า", + "icons.cat.video_and_audio": "วิดีโอและเสียง", + "icons.cat.text_and_data": "ข้อความและข้อมูล", + "icons.cat.share_and_move": "แชร์และย้าย", + "icons.cat.general": "ทั่วไป", "presets.target": "แปลงเป็น", "presets.target_hidden": "เป้าหมายปัจจุบัน ({0}) อยู่ในสาขาที่ปิดไว้เท่านั้น จึงไม่อยู่ในรายการ เป้าหมายนี้จะยังคงอยู่จนกว่าคุณจะเลือกเป้าหมายอื่น", "presets.same_as_source": "ฟอร์แมตเดียวกับต้นฉบับ", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index d8bcdbd3c..1cf48f6f0 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -725,6 +725,12 @@ "toolbar.hole": "İç boşluk", "toolbar.opacity": "Opaklık", "toolbar.captions": "Çıktı biçimini göster", + "toolbar.slice_labels": "Dilim etiketleri", + "toolbar.label_scale": "Etiket boyutu", + "labels.auto": "Otomatik", + "labels.icon_text": "Simge ve metin", + "labels.text_only": "Yalnızca metin", + "labels.icon_only": "Yalnızca simge", "toolbar.new_preset": "Yeni ön ayar", "toolbar.new_group": "Yeni grup", "toolbar.face_front": "Ön", @@ -788,6 +794,23 @@ "presets.save": "Kaydet", "presets.revert": "Geri al", "presets.name": "Ad", + "presets.icon": "Simge", + "presets.icon_default": "Varsayılan", + "presets.icon_none": "Yok", + "presets.icon_search": "Simge ara", + "presets.icon_no_match": "Aramayla eşleşen simge yok.", + "presets.short_name": "Kısa ad", + "presets.short_name_hint": "Halkada adın yerine gösterilir", + "presets.show": "Dilimde göster", + "presets.show_donut": "Halkanın ayarını kullan", + "icons.cat.files_and_folders": "Dosyalar ve klasörler", + "icons.cat.pictures": "Resimler", + "icons.cat.crop_and_transform": "Kırpma ve dönüştürme", + "icons.cat.pdf_and_pages": "PDF ve sayfalar", + "icons.cat.video_and_audio": "Video ve ses", + "icons.cat.text_and_data": "Metin ve veri", + "icons.cat.share_and_move": "Paylaşma ve taşıma", + "icons.cat.general": "Genel", "presets.target": "Hedef biçim", "presets.target_hidden": "Geçerli hedef ({0}) yalnızca kapalı alanlara ait olduğundan listede yer almıyor. Başka bir hedef seçmediğiniz sürece geçerli hedef korunur.", "presets.same_as_source": "Kaynakla aynı biçim", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 1338b682b..49eba108a 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -739,6 +739,12 @@ "toolbar.hole": "Lỗ giữa", "toolbar.opacity": "Độ mờ đục", "toolbar.captions": "Hiển thị định dạng đầu ra", + "toolbar.slice_labels": "Nhãn của các phần", + "toolbar.label_scale": "Cỡ nhãn", + "labels.auto": "Tự động", + "labels.icon_text": "Biểu tượng và chữ", + "labels.text_only": "Chỉ chữ", + "labels.icon_only": "Chỉ biểu tượng", "toolbar.new_preset": "Cấu hình sẵn mới", "toolbar.new_group": "Nhóm mới", "toolbar.face_front": "Mặt trước", @@ -803,6 +809,23 @@ "presets.save": "Lưu", "presets.revert": "Hoàn nguyên", "presets.name": "Tên", + "presets.icon": "Biểu tượng", + "presets.icon_default": "Mặc định", + "presets.icon_none": "Không có", + "presets.icon_search": "Tìm biểu tượng", + "presets.icon_no_match": "Không có biểu tượng nào khớp với tìm kiếm.", + "presets.short_name": "Tên ngắn", + "presets.short_name_hint": "Hiện trên vòng tròn thay cho tên", + "presets.show": "Hiện trên phần", + "presets.show_donut": "Dùng cài đặt của vòng tròn", + "icons.cat.files_and_folders": "Tệp và thư mục", + "icons.cat.pictures": "Ảnh", + "icons.cat.crop_and_transform": "Cắt và biến đổi", + "icons.cat.pdf_and_pages": "PDF và trang", + "icons.cat.video_and_audio": "Video và âm thanh", + "icons.cat.text_and_data": "Văn bản và dữ liệu", + "icons.cat.share_and_move": "Chia sẻ và di chuyển", + "icons.cat.general": "Chung", "presets.target": "Chuyển đổi sang", "presets.target_hidden": "Định dạng đích hiện tại ({0}) chỉ thuộc các lĩnh vực đã tắt nên không có trong danh sách. Định dạng này vẫn là đích trừ khi bạn chọn định dạng khác.", "presets.same_as_source": "Cùng định dạng với tệp nguồn", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index a8d11eab6..f3c0fab37 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -739,6 +739,12 @@ "toolbar.hole": "内圈", "toolbar.opacity": "不透明度", "toolbar.captions": "显示输出格式", + "toolbar.slice_labels": "扇区标签", + "toolbar.label_scale": "标签大小", + "labels.auto": "自动", + "labels.icon_text": "图标和文字", + "labels.text_only": "仅文字", + "labels.icon_only": "仅图标", "toolbar.new_preset": "新预设", "toolbar.new_group": "新建组", "toolbar.face_front": "正面", @@ -803,6 +809,23 @@ "presets.save": "保存", "presets.revert": "还原", "presets.name": "名称", + "presets.icon": "图标", + "presets.icon_default": "默认", + "presets.icon_none": "无", + "presets.icon_search": "搜索图标", + "presets.icon_no_match": "没有与搜索匹配的图标。", + "presets.short_name": "简称", + "presets.short_name_hint": "在圆环上代替名称显示", + "presets.show": "扇区显示", + "presets.show_donut": "使用圆环的设置", + "icons.cat.files_and_folders": "文件和文件夹", + "icons.cat.pictures": "图片", + "icons.cat.crop_and_transform": "裁剪和变换", + "icons.cat.pdf_and_pages": "PDF 和页面", + "icons.cat.video_and_audio": "视频和音频", + "icons.cat.text_and_data": "文本和数据", + "icons.cat.share_and_move": "分享和移动", + "icons.cat.general": "常规", "presets.target": "转换为", "presets.target_hidden": "当前目标({0})仅属于已关闭的领域,因此不在列表中。除非选择其他目标,否则将保留当前目标。", "presets.same_as_source": "与源文件相同的格式", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 697232c9e..160efd665 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -739,6 +739,12 @@ "toolbar.hole": "內圈", "toolbar.opacity": "不透明度", "toolbar.captions": "顯示輸出格式", + "toolbar.slice_labels": "扇形標籤", + "toolbar.label_scale": "標籤大小", + "labels.auto": "自動", + "labels.icon_text": "圖示和文字", + "labels.text_only": "僅文字", + "labels.icon_only": "僅圖示", "toolbar.new_preset": "新預設", "toolbar.new_group": "新增群組", "toolbar.face_front": "正面", @@ -803,6 +809,23 @@ "presets.save": "儲存", "presets.revert": "還原", "presets.name": "名稱", + "presets.icon": "圖示", + "presets.icon_default": "預設", + "presets.icon_none": "無", + "presets.icon_search": "搜尋圖示", + "presets.icon_no_match": "沒有符合搜尋的圖示。", + "presets.short_name": "簡稱", + "presets.short_name_hint": "在圓環上取代名稱顯示", + "presets.show": "扇形顯示", + "presets.show_donut": "使用圓環的設定", + "icons.cat.files_and_folders": "檔案和資料夾", + "icons.cat.pictures": "圖片", + "icons.cat.crop_and_transform": "裁切和變形", + "icons.cat.pdf_and_pages": "PDF 和頁面", + "icons.cat.video_and_audio": "影片和音訊", + "icons.cat.text_and_data": "文字和資料", + "icons.cat.share_and_move": "分享和移動", + "icons.cat.general": "一般", "presets.target": "轉換為", "presets.target_hidden": "目前的目標({0})僅屬於已關閉的領域,因此不在清單中。除非選擇其他目標,否則會保留目前的目標。", "presets.same_as_source": "與來源檔案相同的格式", diff --git a/src/Filee.App/Controls/DonutMenu.Flip.cs b/src/Filee.App/Controls/DonutMenu.Flip.cs index 02e4a0007..963ac691f 100644 --- a/src/Filee.App/Controls/DonutMenu.Flip.cs +++ b/src/Filee.App/Controls/DonutMenu.Flip.cs @@ -59,6 +59,9 @@ public sealed partial class DonutMenu private bool _swapping; // FaceSwapping is being handled (Items change for the flip, not for new files) private Point? _lastExternalAt; // where a drag from outside was last seen on the donut private OldSlice[]? _oldFace; + + /// The old face's items, kept as one list so its labels are laid out once for the whole flip. + private DonutItem[]? _oldFaceItems; private OldGroup? _oldGroup; private double[] _oldDelay = []; private double[] _newDelay = []; @@ -219,6 +222,7 @@ private void PrepareIncoming() private void CaptureOldFace() { var n = Count; + _oldFaceItems = Items!.Take(n).ToArray(); _oldFace = Enumerable.Range(0, n).Select(i => new OldSlice { Item = Items![i], diff --git a/src/Filee.App/Controls/DonutMenu.Groups.cs b/src/Filee.App/Controls/DonutMenu.Groups.cs index 09f31b31c..cfab4e660 100644 --- a/src/Filee.App/Controls/DonutMenu.Groups.cs +++ b/src/Filee.App/Controls/DonutMenu.Groups.cs @@ -63,6 +63,12 @@ public sealed partial class DonutMenu private IReadOnlyList? ExpandedChildren => !IsEditMode && _expanded >= 0 && _expanded < Count ? Items![_expanded].Children : null; + /// + /// How large a group's ring is against the default: it doesn't follow the donut's size (yet). Its labels follow the + /// Label scale as far as its slices (about 30 px wide) have room (). + /// + private const double RingScale = 1; + private (double Inner, double Outer) SubRadii => (OuterRadius + PopOut + SubGap, OuterRadius + PopOut + SubGap + SubBand); /// How far past an open ring (and the gap before it) the pointer still counts as on its way to it. @@ -419,6 +425,10 @@ private void DrawGroupRing(DrawingContext context, Point center, Typeface bold, var transform = Matrix.CreateTranslation(-anchor.X, -anchor.Y) * Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(anchor.X, anchor.Y) * SliceFlipTransform(oldFace, group); + // The ring is drawn at one size whatever the donut's (SubBand, SubGap), so its labels are too: they scale with + // the shape they sit in. + var labels = LabelPlan(children, center, inner, outer, + k => (DonutGeometry.SubMidAngle(parentMid, k, children.Count, subSpan), subSpan), RingScale, bold, regular, fitScale: true); using (context.PushTransform(transform)) using (context.PushOpacity(Math.Clamp(t, 0, 1) * opening)) { @@ -431,8 +441,7 @@ private void DrawGroupRing(DrawingContext context, Point center, Typeface bold, var hot = k == subHover && expanded && item.IsEnabled; if (hot) context.DrawGeometry(WithOpacity(AccentBrush, 0.82), null, wedge); - DrawLabel(context, item, children, center, (inner + outer) / 2, mid, subSpan * (inner + outer) / 2 - 8, - SubBand - 8, bold, regular, hot); + DrawSliceLabel(context, labels[k], item.IsEnabled, hot); } } } diff --git a/src/Filee.App/Controls/DonutMenu.Progress.cs b/src/Filee.App/Controls/DonutMenu.Progress.cs index 3499049b2..c5c81900f 100644 --- a/src/Filee.App/Controls/DonutMenu.Progress.cs +++ b/src/Filee.App/Controls/DonutMenu.Progress.cs @@ -273,6 +273,7 @@ private void RenderProgress(DrawingContext context, Point center, double r1, dou using (context.PushOpacity(1 - collapseAll)) DrawSoftShadow(context, center, r2); + var labels = LabelPlan(Items!, center, r1, r2, i => (DonutGeometry.MidAngle(i, n), DonutGeometry.Span(n)), DonutScale, bold, regular); for (var i = 0; i < n; i++) { var t = Math.Clamp((_collapseClock - i * CollapseStagger) / CollapseSeconds, 0, 1); @@ -294,7 +295,8 @@ private void RenderProgress(DrawingContext context, Point center, double r1, dou if (hot) context.DrawGeometry(WithOpacity(AccentBrush, 0.8), null, wedge); // Labels shrink with their slice instead of vanishing when the collapse starts. - DrawSliceLabel(context, Items![i], Items!, center, r1, r2, DonutGeometry.MidAngle(i, n), DonutGeometry.Span(n), bold, regular, hot); + DrawSliceLabel(context, labels[i], Items![i].IsEnabled, hot); + DrawGroupMark(context, Items![i], center, r2, DonutGeometry.MidAngle(i, n), hot); } } diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index 6cb1cd488..267133465 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -7,6 +7,7 @@ // // After a drop the donut turns into a progress ring: see DonutMenu.Progress.cs. Group slices, which fan out a ring // of presets, and the keyboard: DonutMenu.Groups.cs. Tab (or the wheel) flips the donut over: DonutMenu.Flip.cs. +// How each slice's name and icon fit inside it (the safe zone, one size and one style per ring): SliceLabelLayout.cs. using System.Globalization; using Avalonia; @@ -16,6 +17,7 @@ using Avalonia.Media; using Avalonia.Threading; using Filee.App.Services; +using Filee.Core.Presets; namespace Filee.App.Controls; @@ -48,6 +50,15 @@ public sealed class DonutItem /// public IReadOnlyList? Children { get; init; } + /// The preset's full name, for the centre hint: the slice may show an icon or a shorter name. Null: . + public string? FullName { get; init; } + + /// The icon the slice can show (a key), or null for none. + public string? IconKey { get; init; } + + /// What the slice shows. Null: the donut's . + public LabelShow? Show { get; init; } + /// A group slice ( set), even one left with nothing to fan out. public bool IsGroup => Children is not null; @@ -76,6 +87,10 @@ public sealed partial class DonutMenu : Control AvaloniaProperty.Register(nameof(AllowContextEdit)); public static readonly StyledProperty ReserveSubRingProperty = AvaloniaProperty.Register(nameof(ReserveSubRing)); + public static readonly StyledProperty LabelScaleProperty = + AvaloniaProperty.Register(nameof(LabelScale), 1); + public static readonly StyledProperty DefaultShowProperty = + AvaloniaProperty.Register(nameof(DefaultShow), LabelShow.Auto); public static readonly StyledProperty ShowCaptionsProperty = AvaloniaProperty.Register(nameof(ShowCaptions), true); @@ -146,7 +161,7 @@ static DonutMenu() AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, IsEditModeProperty, SliceBrushProperty, SliceBorderBrushProperty, CenterBrushProperty, AccentBrushProperty, OnAccentBrushProperty, ForegroundProperty, SecondaryForegroundProperty, SuccessBrushProperty, CornerRadiusProperty, - CenterHintProperty, CenterHintWheelProperty, ShowCaptionsProperty); + CenterHintProperty, CenterHintWheelProperty, ShowCaptionsProperty, LabelScaleProperty, DefaultShowProperty); ItemsProperty.Changed.AddClassHandler((d, _) => d.OnItemsChanged()); } @@ -191,6 +206,15 @@ public DonutMenu() /// public bool ShowCaptions { get => GetValue(ShowCaptionsProperty); set => SetValue(ShowCaptionsProperty, value); } + /// + /// How large the slices' names and icons are (the donut's Label scale, 0.8 to 1.6); they also follow the donut's size + /// (). + /// + public double LabelScale { get => GetValue(LabelScaleProperty); set => SetValue(LabelScaleProperty, value); } + + /// What a slice shows when its item doesn't say (). + public LabelShow DefaultShow { get => GetValue(DefaultShowProperty); set => SetValue(DefaultShowProperty, value); } + public CornerRadius CornerRadius { get => GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } public IBrush? SliceBrush { get => GetValue(SliceBrushProperty); set => SetValue(SliceBrushProperty, value); } @@ -899,6 +923,11 @@ private void RenderRing(DrawingContext context, Point center, double r1, double context.DrawGeometry(WithOpacity(AccentBrush, 0.35), null, ring); } + var labels = n > 0 + ? LabelPlan(Items!, center, r1, r2, i => IsEditMode && i < _mid.Length + ? (_mid[i].Value, _span[i].Value) + : (DonutGeometry.MidAngle(i, n), DonutGeometry.Span(n)), DonutScale, typeface, typefaceRegular) + : []; for (var i = 0; i < n; i++) { if (IsFlipping && SliceFlipWidth(false, i) == 0) @@ -938,14 +967,14 @@ private void RenderRing(DrawingContext context, Point center, double r1, double context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(emphasis, 0, 1)), null, wedge); var hot = (i == ActiveIndex || i == _dropTargetIndex) && item.IsEnabled; - DrawSliceLabel(context, item, Items!, center, r1, r2, mid, span, typeface, typefaceRegular, hot); + DrawSliceLabel(context, labels[i], item.IsEnabled, hot); + DrawGroupMark(context, item, center, r2, mid, hot); } } - if (_oldFace is { } old) + if (_oldFace is { } old && _oldFaceItems is { } oldItems) { - // Items holds the incoming face by now: the outgoing slices are compared with their own face. - var oldItems = Array.ConvertAll(old, slice => slice.Item); + var oldLabels = LabelPlan(oldItems, center, r1, r2, i => (old[i].Mid, old[i].Span), DonutScale, typeface, typefaceRegular); for (var i = 0; i < old.Length; i++) { var slice = old[i]; @@ -965,8 +994,8 @@ private void RenderRing(DrawingContext context, Point center, double r1, double context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (slice.Item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); if (slice.Emphasis > 0.01 && slice.Item.IsEnabled) context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(slice.Emphasis, 0, 1)), null, wedge); - DrawSliceLabel(context, slice.Item, oldItems, center, r1, r2, slice.Mid, slice.Span, typeface, - typefaceRegular, slice.Hot && slice.Item.IsEnabled); + DrawSliceLabel(context, oldLabels[i], slice.Item.IsEnabled, slice.Hot && slice.Item.IsEnabled); + DrawGroupMark(context, slice.Item, center, r2, slice.Mid, slice.Hot && slice.Item.IsEnabled); } } } @@ -1003,7 +1032,11 @@ private void DrawCenter(DrawingContext context, Point center, double r1, Typefac if (cancelHot) context.DrawEllipse(WithOpacity(DangerBrush, 0.18), null, center, radius, radius); - var maxWidth = radius * 1.6; + // The text and its icons grow and shrink with the donut, as the hub does. + var scale = DonutScale; + using var scaled = context.PushTransform(Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateScale(scale, scale) * + Matrix.CreateTranslation(center.X, center.Y)); + var maxWidth = radius * 1.6 / scale; var title = CachedText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); var subtitle = CachedText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, maxWidth, maxLines: 2); // "⟳ Tab back": small, under the rest, a little narrower (the hub is round), with room for its icons. @@ -1097,44 +1130,128 @@ private static void DrawMouse(DrawingContext context, Rect box, IBrush? brush) context.DrawLine(pen, new Point(box.Center.X, box.Top + 2), new Point(box.Center.X, box.Top + 4.5)); } - /// The slices is shown among: its face, or its group's ring. - private void DrawLabel(DrawingContext context, DonutItem item, IReadOnlyList face, Point center, - double radius, double angle, double maxWidth, double maxHeight, Typeface bold, Typeface regular, bool hot) + // ───────────────────────── Slice labels ───────────────────────── + + /// + /// How large the donut is against the default size (radius 150): names, icons, margins, the chevron and the centre + /// text grow and shrink with it. + /// + internal double DonutScale => OuterRadius / SliceLabelLayout.DefaultRadius; + + private SliceLabelLayout? _labelLayout; + private readonly Dictionary> _labelPlans = new(); + private const int MaxCachedPlans = 64; + + // A ring's labels are laid out again only when something they depend on changes: its items, its slices' shapes, the + // donut's size or the label settings. Animations move the drawn labels with transforms. + private readonly record struct LabelPlanKey(IReadOnlyList Items, Point Center, double Inner, double Outer, + int Shapes, double Scale, double LabelScale, bool Captions, LabelShow Show); + + private SliceLabelLayout LabelLayout(Typeface bold, Typeface regular) { - var foreground = hot ? OnAccentBrush : item.IsEnabled ? Foreground : SecondaryForeground; - var width = Math.Clamp(maxWidth, 30, 120); - var text = KeepKoreanWords(item.Label); - var label = CachedText(text, bold, LabelSize(text, bold, width), foreground, width, maxLines: 2); - var showCaption = (ShowCaptions || item.KeepsCaption || SharesItsName(item, face)) && - !string.IsNullOrEmpty(item.Caption) && - !string.Equals(item.Caption, item.Label, StringComparison.OrdinalIgnoreCase) && - label.Height + 14 < maxHeight; - var caption = showCaption - ? CachedText(item.Caption!, regular, 10.5, hot ? OnAccentBrush : SecondaryForeground, width, - maxLines: label.Height + 28 < maxHeight ? 2 : 1) - : null; - - var total = label.Height + (caption?.Height ?? 0); - var anchor = DonutGeometry.PointAt(center, radius, angle); - var y = anchor.Y - total / 2; - DrawCentered(context, label, anchor.X, y); - if (caption is not null) - DrawCentered(context, caption, anchor.X, y + label.Height); + var fonts = new LabelFonts(bold, regular, TextDirection); + if (_labelLayout is null || _labelLayout.Fonts != fonts) + { + _labelLayout = new SliceLabelLayout(fonts); + _labelPlans.Clear(); + } + return _labelLayout; } - /// Another slice of has the name, and only the caption tells them apart. - private static bool SharesItsName(DonutItem item, IReadOnlyList face) => - face.Any(other => !ReferenceEquals(other, item) && string.Equals(other.Label, item.Label, StringComparison.OrdinalIgnoreCase)); + /// + /// The labels of one ring of between and , + /// slice i centred on slice(i).Mid and slice(i).Span wide. + /// + private IReadOnlyList LabelPlan(IReadOnlyList items, Point center, double inner, double outer, + Func slice, double scale, Typeface bold, Typeface regular, bool fitScale = false) + { + var layout = LabelLayout(bold, regular); + var shapes = new HashCode(); + for (var i = 0; i < items.Count; i++) + { + var (mid, span) = slice(i); + shapes.Add(mid); + shapes.Add(span); + } + var key = new LabelPlanKey(items, center, inner, outer, shapes.ToHashCode(), scale, LabelScale, ShowCaptions, DefaultShow); + if (_labelPlans.TryGetValue(key, out var plan)) + return plan; + if (_labelPlans.Count >= MaxCachedPlans) + _labelPlans.Clear(); + var inputs = items.Select((item, i) => + { + var (mid, span) = slice(i); + return new SliceLabelInput(item.Label, item.Caption, item.IconKey, item.Show ?? DefaultShow, + new SliceShape(center, inner, outer, mid, span, GapPixels, item.IsGroup ? ChevronKeepOut : 0)); + }).ToList(); + PlansMade++; + return _labelPlans[key] = fitScale + ? layout.PlanRingToFit(inputs, LabelScale, scale, ShowCaptions) + : layout.PlanRing(inputs, LabelScale, scale, ShowCaptions); + } + + /// How many ring layouts were worked out (tests: not one per frame). + internal int PlansMade { get; private set; } + + /// + /// The labels of the face (at rest) and of every group's ring, laid out as they are drawn, in the given fonts (tests: + /// whether names fit, without drawing). + /// + internal (IReadOnlyList Face, IReadOnlyList> Rings) PlanLabels(Typeface bold, Typeface regular) + { + var items = Items ?? []; + var n = items.Count; + var center = new Point(OuterRadius + SubRingSpace, OuterRadius + SubRingSpace); + var face = n == 0 ? [] : LabelPlan(items, center, InnerRadius, OuterRadius, + i => (DonutGeometry.MidAngle(i, n), DonutGeometry.Span(n)), DonutScale, bold, regular); + var (inner, outer) = SubRadii; + var rings = items.Select((item, group) => + { + if (item.Children is not { Count: > 0 } children) + return (IReadOnlyList)[]; + var span = SubSpanOf(children.Count); + var mid = DonutGeometry.MidAngle(group, n); + return LabelPlan(children, center, inner, outer, + k => (DonutGeometry.SubMidAngle(mid, k, children.Count, span), span), RingScale, bold, regular, fitScale: true); + }).ToList(); + return (face, rings); + } - /// A slice's name and caption, and a group's mark, the name moved in to leave the mark room. - private void DrawSliceLabel(DrawingContext context, DonutItem item, IReadOnlyList face, Point center, - double r1, double r2, double mid, double span, Typeface bold, Typeface regular, bool hot) + /// Draws a slice's laid-out icon and name in its text colour (on the accent when it's highlighted). + private void DrawSliceLabel(DrawingContext context, SliceLabel label, bool enabled, bool hot) { - var room = item.IsGroup ? GroupMarkRoom * GroupMarkScale : 0; - DrawLabel(context, item, face, center, (r1 + r2 - room) / 2, mid, span * (r1 + r2) / 2 - 10, r2 - r1 - 16, bold, regular, hot); - DrawGroupMark(context, item, center, r2, mid, hot); + var brush = hot ? OnAccentBrush : enabled ? Foreground : SecondaryForeground; + if (label.IconSize > 0 && SliceIcons.Geometry(label.IconKey) is { } icon) + { + var k = label.IconSize / SliceIcons.Box; + using (context.PushTransform(Matrix.CreateTranslation(-SliceIcons.Box / 2, -SliceIcons.Box / 2) * + Matrix.CreateScale(k, k) * + Matrix.CreateTranslation(label.IconCenter.X, label.IconCenter.Y))) + context.DrawGeometry(brush, null, icon); + } + if (label.Text is not { } text || _labelLayout is not { } layout) + return; + var fonts = layout.Fonts; + var at = text.Anchor; + using (context.PushTransform(Matrix.CreateTranslation(-at.X, -at.Y) * Matrix.CreateRotation(text.Rotation) * + Matrix.CreateTranslation(at.X, at.Y))) + { + var y = at.Y - text.Height / 2; + for (var i = 0; i < text.Lines.Count; i++) + { + DrawCentered(context, SliceLabelLayout.MakeLine(text.Lines[i], fonts.Bold, text.Size, brush, fonts.Direction, text.Width), at.X, y); + y += text.LineHeights[i]; + } + if (text.Caption is { } caption) + DrawCentered(context, SliceLabelLayout.MakeLine(caption, fonts.Regular, text.CaptionSize, + hot ? OnAccentBrush : SecondaryForeground, fonts.Direction, text.Width), at.X, y); + } } + /// Another slice of has the name, and only the caption tells them apart. + private static bool SharesItsName(DonutItem item, IReadOnlyList face) => + face.Any(other => !ReferenceEquals(other, item) && string.Equals(other.Label, item.Label, StringComparison.OrdinalIgnoreCase)); + /// /// A group slice's mark: a small chevron by its outer edge pointing outward, where its ring fans out, as a /// submenu's ▸ points to where it opens. Drawn inside the slice's transform, it opens, pops out and flips with it. @@ -1146,7 +1263,7 @@ private void DrawGroupMark(DrawingContext context, DonutItem item, Point center, var brush = hot ? OnAccentBrush : SecondaryForeground; if (brush is null) return; - var size = GroupMarkScale; + var size = DonutScale; var pen = new Pen(brush, GroupMarkStroke * size, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round); using (context.PushOpacity(item.IsEnabled ? 1 : 0.5)) context.DrawGeometry(null, pen, CreateGroupMark(center, r2, mid, size)); @@ -1181,52 +1298,17 @@ private static StreamGeometry BuildGroupMark(Point c, double r2, double mid, dou return mark; } - /// The group mark's size follows the donut's: 1 at the default radius. - private double GroupMarkScale => OuterRadius / DefaultOuterRadius; - private const double DefaultOuterRadius = 150; // The group mark at the default radius: its tip this far in from the outer edge, its arms this far back and this - // far to each side, its line this thick, and how far a group's text area gives way for it. - private const double GroupMarkInset = 7, GroupMarkDepth = 4.5, GroupMarkHalfWidth = 5, GroupMarkStroke = 1.6, GroupMarkRoom = 6; + // far to each side, and its line this thick. + private const double GroupMarkInset = 7, GroupMarkDepth = 4.5, GroupMarkHalfWidth = 5, GroupMarkStroke = 1.6; - /// - /// A slice's caption under its name: one line, or two where the slice is deep enough, so a caption wider than the - /// slice ("Same format" on a small donut) wraps instead of ending in "Same for…". - /// - internal static FormattedText MakeCaption(string caption, Typeface typeface, IBrush? brush, double width, - double labelHeight, double maxHeight) => - MakeText(caption, typeface, 10.5, brush, width, maxLines: labelHeight + 28 < maxHeight ? 2 : 1); - - private const double LabelFontSize = 13; - private const double MinLabelFontSize = 10; - private readonly Dictionary<(string Text, Typeface Typeface, double Width), double> _labelSizes = new(); - - /// - /// The size a slice's name is drawn at: 13, or smaller (down to 10) when one of its words is wider than the slice - /// (), which would otherwise break in the middle ("Grayscal|e" on a thin slice). Words in - /// Chinese or Japanese are left out: those break between any two characters. - /// - internal double LabelSize(string text, Typeface typeface, double width) - { - var line = Math.Round(width); - var key = (text, typeface, line); - if (_labelSizes.TryGetValue(key, out var size)) - return size; - if (_labelSizes.Count >= MaxCachedWedges) - _labelSizes.Clear(); - var widest = text.Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Where(word => !word.Any(IsHanOrKana)) - .Select(word => new FormattedText(word, CultureInfo.CurrentUICulture, TextDirection, typeface, LabelFontSize, null).Width) - .DefaultIfEmpty(0) - .Max(); - // A pixel to spare: a word exactly as wide as the line still wraps. - size = widest < line ? LabelFontSize : Math.Max(MinLabelFontSize, LabelFontSize * (line - 1) / widest); - return _labelSizes[key] = size; - } + /// How far in from a group slice's outer edge its label keeps clear of the chevron. + private double ChevronKeepOut => (GroupMarkInset + GroupMarkDepth + 4) * DonutScale; /// Chinese characters, kana and the CJK symbols and full-width forms: a line may break between any two. - private static bool IsHanOrKana(char c) => + internal static bool IsHanOrKana(char c) => c is >= '\u2E80' and <= '\u312F' or >= '\u3190' and <= '\u9FFF' or >= '\uF900' and <= '\uFAFF' or >= '\uFF00' and <= '\uFFEF'; /// @@ -1319,8 +1401,16 @@ private void DrawPreview(DrawingContext context, Point center, double r1, double if (_previewLabel is null || span < 0.25) return; using (context.PushOpacity(Math.Clamp((span - 0.25) / 0.3, 0, 1) * 0.9)) - DrawSliceLabel(context, new DonutItem { Id = "", Label = _previewLabel, Children = _previewGroup ? [] : null }, [], center, - r1, r2, mid, span, bold, regular, hot: false); + { + // One slice of its own: laid out each frame as it grows in, never cached. + var layout = LabelLayout(bold, regular); + var label = layout.PlanRing([new SliceLabelInput(_previewLabel, null, null, LabelShow.TextOnly, + new SliceShape(center, r1, r2, mid, span, GapPixels, _previewGroup ? ChevronKeepOut : 0))], LabelScale, DonutScale, + false)[0]; + DrawSliceLabel(context, label, enabled: true, hot: false); + if (_previewGroup) + DrawGroupMark(context, new DonutItem { Id = "", Label = _previewLabel, Children = [] }, center, r2, mid, hot: false); + } } /// diff --git a/src/Filee.App/Controls/SliceIcons.Data.cs b/src/Filee.App/Controls/SliceIcons.Data.cs new file mode 100644 index 000000000..8bff3113f --- /dev/null +++ b/src/Filee.App/Controls/SliceIcons.Data.cs @@ -0,0 +1,204 @@ +// Generated from the slice icon list reviewed with the user (2026-10-29): Material Design Icons by Pictogrammers +// (Apache-2.0, @mdi/svg 7.4.47; see THIRD-PARTY-NOTICES.md), 24 × 24 path data. Keys are the icons' own names. + +namespace Filee.App.Controls; + +internal static partial class SliceIcons +{ + /// Every icon a slice can show, by key: its path data and English search words. + private static readonly (string Key, string Path, string Words)[] Data = + [ + ("file-outline", "M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z", "file"), + ("file-multiple-outline", "M16 0H8C6.9 0 6 .9 6 2V18C6 19.1 6.9 20 8 20H20C21.1 20 22 19.1 22 18V6L16 0M20 18H8V2H15V7H20V18M4 4V22H20V24H4C2.9 24 2 23.1 2 22V4H4Z", "file multiple merge pages documents"), + ("file-plus-outline", "M13.81 22H6C4.89 22 4 21.11 4 20V4C4 2.9 4.89 2 6 2H14L20 8V13.09C19.67 13.04 19.34 13 19 13S18.33 13.04 18 13.09V9H13V4H6V20H13.09C13.21 20.72 13.46 21.39 13.81 22M23 18H20V15H18V18H15V20H18V23H20V20H23V18Z", "file plus add pages new"), + ("file-swap-outline", "M17 15L14 12V14H10V12L7 15L10 18V16H14V18M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2M18 20H6V4H13V9H18Z", "file swap reorder pages"), + ("file-document-outline", "M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z", "file document"), + ("file-document-edit-outline", "M8,12H16V14H8V12M10,20H6V4H13V9H18V12.1L20,10.1V8L14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H10V20M8,18H12.1L13,17.1V16H8V18M20.2,13C20.3,13 20.5,13.1 20.6,13.2L21.9,14.5C22.1,14.7 22.1,15.1 21.9,15.3L20.9,16.3L18.8,14.2L19.8,13.2C19.9,13.1 20,13 20.2,13M20.2,16.9L14.1,23H12V20.9L18.1,14.8L20.2,16.9Z", "file document edit pdf sheet studio"), + ("file-image-outline", "M14,2L20,8V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2H14M18,20V9H13V4H6V20H18M17,13V19H7L12,14L14,16M10,10.5A1.5,1.5 0 0,1 8.5,12A1.5,1.5 0 0,1 7,10.5A1.5,1.5 0 0,1 8.5,9A1.5,1.5 0 0,1 10,10.5Z", "file image"), + ("file-pdf-box", "M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z", "file pdf"), + ("file-video-outline", "M14,2L20,8V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2H14M18,20V9H13V4H6V20H18M16,18L13.5,16.3V18H8V13H13.5V14.7L16,13V18Z", "file video"), + ("file-music-outline", "M14,2L20,8V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2H14M18,20V9H13V4H6V20H18M13,10V12H11V17A2,2 0 0,1 9,19A2,2 0 0,1 7,17A2,2 0 0,1 9,15C9.4,15 9.7,15.1 10,15.3V10H13Z", "file music"), + ("file-table-outline", "M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M10,13H7V11H10V13M14,13H11V11H14V13M10,16H7V14H10V16M14,16H11V14H14V16M10,19H7V17H10V19M14,19H11V17H14V19Z", "file table"), + ("file-presentation-box", "M19,16H5V8H19M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z", "file presentation"), + ("text-box-outline", "M5,3C3.89,3 3,3.89 3,5V19C3,20.11 3.89,21 5,21H19C20.11,21 21,20.11 21,19V5C21,3.89 20.11,3 19,3H5M5,5H19V19H5V5M7,7V9H17V7H7M7,11V13H17V11H7M7,15V17H14V15H7Z", "text"), + ("folder-outline", "M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z", "folder"), + ("folder-move-outline", "M20 18H4V8H20V18M12 6L10 4H4C2.9 4 2 4.89 2 6V18C2 19.11 2.9 20 4 20H20C21.11 20 22 19.11 22 18V8C22 6.9 21.11 6 20 6H12M11 14V12H15V9L19 13L15 17V14H11Z", "folder move"), + ("folder-open-outline", "M6.1,10L4,18V8H21A2,2 0 0,0 19,6H12L10,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H19C19.9,20 20.7,19.4 20.9,18.5L23.2,10H6.1M19,18H6L7.6,12H20.6L19,18Z", "folder open"), + ("folder-zip-outline", "M20 6H12L10 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V8C22 6.9 21.1 6 20 6M20 18H16V16H14V18H4V8H14V10H16V8H20V18M16 12V10H18V12H16M14 12H16V14H14V12M18 16H16V14H18V16Z", "folder zip"), + ("package-variant", "M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.85,13.36L20.13,9.73L19.55,8.72L13.27,12.35L13.85,13.36Z", "package extract unzip open archive"), + ("zip-box-outline", "M12 17V15H14V17H12M14 13V11H12V13H14M14 9V7H12V9H14M10 11H12V9H10V11M10 15H12V13H10V15M21 5V19C21 20.1 20.1 21 19 21H5C3.9 21 3 20.1 3 19V5C3 3.9 3.9 3 5 3H19C20.1 3 21 3.9 21 5M19 5H12V7H10V5H5V19H19V5Z", "zip archive compress"), + ("archive-outline", "M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M9.5 11H14.5C14.78 11 15 11.22 15 11.5V13H9V11.5C9 11.22 9.22 11 9.5 11M5 5V7H19V5H5Z", "archive"), + ("image-outline", "M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z", "image"), + ("image-multiple-outline", "M21,17H7V3H21M21,1H7A2,2 0 0,0 5,3V17A2,2 0 0,0 7,19H21A2,2 0 0,0 23,17V3A2,2 0 0,0 21,1M3,5H1V21A2,2 0 0,0 3,23H19V21H3M15.96,10.29L13.21,13.83L11.25,11.47L8.5,15H19.5L15.96,10.29Z", "image multiple merge pictures photos gallery"), + ("image-edit-outline", "M22.7 14.3L21.7 15.3L19.7 13.3L20.7 12.3C20.8 12.2 20.9 12.1 21.1 12.1C21.2 12.1 21.4 12.2 21.5 12.3L22.8 13.6C22.9 13.8 22.9 14.1 22.7 14.3M13 19.9V22H15.1L21.2 15.9L19.2 13.9L13 19.9M11.21 15.83L9.25 13.47L6.5 17H13.12L15.66 14.55L13.96 12.29L11.21 15.83M11 19.9V19.05L11.05 19H5V5H19V11.31L21 9.38V5C21 3.9 20.11 3 19 3H5C3.9 3 3 3.9 3 5V19C3 20.11 3.9 21 5 21H11V19.9Z", "image edit picture adjust"), + ("image-filter-black-white", "M19,19L12,11V19H5L12,11V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3Z", "image filter black white grayscale monochrome"), + ("image-filter-vintage", "M12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16M18.7,12.4C18.42,12.24 18.13,12.11 17.84,12C18.13,11.89 18.42,11.76 18.7,11.6C20.62,10.5 21.69,8.5 21.7,6.41C19.91,5.38 17.63,5.3 15.7,6.41C15.42,6.57 15.16,6.76 14.92,6.95C14.97,6.64 15,6.32 15,6C15,3.78 13.79,1.85 12,0.81C10.21,1.85 9,3.78 9,6C9,6.32 9.03,6.64 9.08,6.95C8.84,6.75 8.58,6.56 8.3,6.4C6.38,5.29 4.1,5.37 2.3,6.4C2.3,8.47 3.37,10.5 5.3,11.59C5.58,11.75 5.87,11.88 6.16,12C5.87,12.1 5.58,12.23 5.3,12.39C3.38,13.5 2.31,15.5 2.3,17.58C4.09,18.61 6.37,18.69 8.3,17.58C8.58,17.42 8.84,17.23 9.08,17.04C9.03,17.36 9,17.68 9,18C9,20.22 10.21,22.15 12,23.19C13.79,22.15 15,20.22 15,18C15,17.68 14.97,17.36 14.92,17.05C15.16,17.25 15.42,17.43 15.7,17.59C17.62,18.7 19.9,18.62 21.7,17.59C21.69,15.5 20.62,13.5 18.7,12.4Z", "image filter vintage"), + ("auto-fix", "M7.5,5.6L5,7L6.4,4.5L5,2L7.5,3.4L10,2L8.6,4.5L10,7L7.5,5.6M19.5,15.4L22,14L20.6,16.5L22,19L19.5,17.6L17,19L18.4,16.5L17,14L19.5,15.4M22,2L20.6,4.5L22,7L19.5,5.6L17,7L18.4,4.5L17,2L19.5,3.4L22,2M13.34,12.78L15.78,10.34L13.66,8.22L11.22,10.66L13.34,12.78M14.37,7.29L16.71,9.63C17.1,10 17.1,10.65 16.71,11.04L5.04,22.71C4.65,23.1 4,23.1 3.63,22.71L1.29,20.37C0.9,20 0.9,19.35 1.29,18.96L12.96,7.29C13.35,6.9 14,6.9 14.37,7.29Z", "auto fix magic wand remove background cutout"), + ("blur", "M14,8.5A1.5,1.5 0 0,0 12.5,10A1.5,1.5 0 0,0 14,11.5A1.5,1.5 0 0,0 15.5,10A1.5,1.5 0 0,0 14,8.5M14,12.5A1.5,1.5 0 0,0 12.5,14A1.5,1.5 0 0,0 14,15.5A1.5,1.5 0 0,0 15.5,14A1.5,1.5 0 0,0 14,12.5M10,17A1,1 0 0,0 9,18A1,1 0 0,0 10,19A1,1 0 0,0 11,18A1,1 0 0,0 10,17M10,8.5A1.5,1.5 0 0,0 8.5,10A1.5,1.5 0 0,0 10,11.5A1.5,1.5 0 0,0 11.5,10A1.5,1.5 0 0,0 10,8.5M14,20.5A0.5,0.5 0 0,0 13.5,21A0.5,0.5 0 0,0 14,21.5A0.5,0.5 0 0,0 14.5,21A0.5,0.5 0 0,0 14,20.5M14,17A1,1 0 0,0 13,18A1,1 0 0,0 14,19A1,1 0 0,0 15,18A1,1 0 0,0 14,17M21,13.5A0.5,0.5 0 0,0 20.5,14A0.5,0.5 0 0,0 21,14.5A0.5,0.5 0 0,0 21.5,14A0.5,0.5 0 0,0 21,13.5M18,5A1,1 0 0,0 17,6A1,1 0 0,0 18,7A1,1 0 0,0 19,6A1,1 0 0,0 18,5M18,9A1,1 0 0,0 17,10A1,1 0 0,0 18,11A1,1 0 0,0 19,10A1,1 0 0,0 18,9M18,17A1,1 0 0,0 17,18A1,1 0 0,0 18,19A1,1 0 0,0 19,18A1,1 0 0,0 18,17M18,13A1,1 0 0,0 17,14A1,1 0 0,0 18,15A1,1 0 0,0 19,14A1,1 0 0,0 18,13M10,12.5A1.5,1.5 0 0,0 8.5,14A1.5,1.5 0 0,0 10,15.5A1.5,1.5 0 0,0 11.5,14A1.5,1.5 0 0,0 10,12.5M10,7A1,1 0 0,0 11,6A1,1 0 0,0 10,5A1,1 0 0,0 9,6A1,1 0 0,0 10,7M10,3.5A0.5,0.5 0 0,0 10.5,3A0.5,0.5 0 0,0 10,2.5A0.5,0.5 0 0,0 9.5,3A0.5,0.5 0 0,0 10,3.5M10,20.5A0.5,0.5 0 0,0 9.5,21A0.5,0.5 0 0,0 10,21.5A0.5,0.5 0 0,0 10.5,21A0.5,0.5 0 0,0 10,20.5M3,13.5A0.5,0.5 0 0,0 2.5,14A0.5,0.5 0 0,0 3,14.5A0.5,0.5 0 0,0 3.5,14A0.5,0.5 0 0,0 3,13.5M14,3.5A0.5,0.5 0 0,0 14.5,3A0.5,0.5 0 0,0 14,2.5A0.5,0.5 0 0,0 13.5,3A0.5,0.5 0 0,0 14,3.5M14,7A1,1 0 0,0 15,6A1,1 0 0,0 14,5A1,1 0 0,0 13,6A1,1 0 0,0 14,7M21,10.5A0.5,0.5 0 0,0 21.5,10A0.5,0.5 0 0,0 21,9.5A0.5,0.5 0 0,0 20.5,10A0.5,0.5 0 0,0 21,10.5M6,5A1,1 0 0,0 5,6A1,1 0 0,0 6,7A1,1 0 0,0 7,6A1,1 0 0,0 6,5M3,9.5A0.5,0.5 0 0,0 2.5,10A0.5,0.5 0 0,0 3,10.5A0.5,0.5 0 0,0 3.5,10A0.5,0.5 0 0,0 3,9.5M6,9A1,1 0 0,0 5,10A1,1 0 0,0 6,11A1,1 0 0,0 7,10A1,1 0 0,0 6,9M6,17A1,1 0 0,0 5,18A1,1 0 0,0 6,19A1,1 0 0,0 7,18A1,1 0 0,0 6,17M6,13A1,1 0 0,0 5,14A1,1 0 0,0 6,15A1,1 0 0,0 7,14A1,1 0 0,0 6,13Z", "blur pixelate hide redact"), + ("palette-outline", "M12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2C17.5,2 22,6 22,11A6,6 0 0,1 16,17H14.2C13.9,17 13.7,17.2 13.7,17.5C13.7,17.6 13.8,17.7 13.8,17.8C14.2,18.3 14.4,18.9 14.4,19.5C14.5,20.9 13.4,22 12,22M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C12.3,20 12.5,19.8 12.5,19.5C12.5,19.3 12.4,19.2 12.4,19.1C12,18.6 11.8,18.1 11.8,17.5C11.8,16.1 12.9,15 14.3,15H16A4,4 0 0,0 20,11C20,7.1 16.4,4 12,4M6.5,10C7.3,10 8,10.7 8,11.5C8,12.3 7.3,13 6.5,13C5.7,13 5,12.3 5,11.5C5,10.7 5.7,10 6.5,10M9.5,6C10.3,6 11,6.7 11,7.5C11,8.3 10.3,9 9.5,9C8.7,9 8,8.3 8,7.5C8,6.7 8.7,6 9.5,6M14.5,6C15.3,6 16,6.7 16,7.5C16,8.3 15.3,9 14.5,9C13.7,9 13,8.3 13,7.5C13,6.7 13.7,6 14.5,6M17.5,10C18.3,10 19,10.7 19,11.5C19,12.3 18.3,13 17.5,13C16.7,13 16,12.3 16,11.5C16,10.7 16.7,10 17.5,10Z", "palette"), + ("brush-outline", "M7 16C7.55 16 8 16.45 8 17C8 18.1 7.1 19 6 19C5.83 19 5.67 19 5.5 18.95C5.81 18.4 6 17.74 6 17C6 16.45 6.45 16 7 16M18.67 3C18.41 3 18.16 3.1 17.96 3.29L9 12.25L11.75 15L20.71 6.04C21.1 5.65 21.1 5 20.71 4.63L19.37 3.29C19.17 3.09 18.92 3 18.67 3M7 14C5.34 14 4 15.34 4 17C4 18.31 2.84 19 2 19C2.92 20.22 4.5 21 6 21C8.21 21 10 19.21 10 17C10 15.34 8.66 14 7 14Z", "brush"), + ("eyedropper-variant", "M6.92,19L5,17.08L13.06,9L15,10.94M20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L13.84,6.41L11.91,4.5L10.5,5.91L11.92,7.33L3,16.25V21H7.75L16.67,12.08L18.09,13.5L19.5,12.09L17.58,10.17L20.7,7.05C21.1,6.65 21.1,6 20.71,5.63Z", "eyedropper"), + ("contrast-circle", "M12,20C9.79,20 7.79,19.1 6.34,17.66L17.66,6.34C19.1,7.79 20,9.79 20,12A8,8 0 0,1 12,20M6,8H8V6H9.5V8H11.5V9.5H9.5V11.5H8V9.5H6M12,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,16H17V14.5H12V16Z", "contrast circle"), + ("brightness-6", "M12,18V6A6,6 0 0,1 18,12A6,6 0 0,1 12,18M20,15.31L23.31,12L20,8.69V4H15.31L12,0.69L8.69,4H4V8.69L0.69,12L4,15.31V20H8.69L12,23.31L15.31,20H20V15.31Z", "brightness 6"), + ("water-outline", "M12,3.77L11.25,4.61C11.25,4.61 9.97,6.06 8.68,7.94C7.39,9.82 6,12.07 6,14.23A6,6 0 0,0 12,20.23A6,6 0 0,0 18,14.23C18,12.07 16.61,9.82 15.32,7.94C14.03,6.06 12.75,4.61 12.75,4.61L12,3.77M12,6.9C12.44,7.42 12.84,7.85 13.68,9.07C14.89,10.83 16,13.07 16,14.23C16,16.45 14.22,18.23 12,18.23C9.78,18.23 8,16.45 8,14.23C8,13.07 9.11,10.83 10.32,9.07C11.16,7.85 11.56,7.42 12,6.9Z", "water"), + ("sticker-outline", "M5.5 2C3.6 2 2 3.6 2 5.5V18.5C2 20.4 3.6 22 5.5 22H16L22 16V5.5C22 3.6 20.4 2 18.5 2H5.5M5.8 4H18.3C19.3 4 20.1 4.8 20.1 5.8V15H18.6C16.7 15 15.1 16.6 15.1 18.5V20H5.8C4.8 20 4 19.2 4 18.2V5.8C4 4.8 4.8 4 5.8 4", "sticker"), + ("image-frame", "M10,14.29L6.5,19H17.46L14.75,15.46L12.78,17.8L10,14.29M5,21V7H18.96V21H5M12,2.4L14.61,5.03H9.37L12,2.4M5,5.03C4.5,5.03 4,5.22 3.61,5.61C3.2,6 3,6.46 3,7V21C3,21.5 3.2,22 3.61,22.39C4,22.8 4.5,23 5,23H18.96C19.5,23 19.96,22.8 20.37,22.39C20.77,22 21,21.5 21,21V7C21,6.46 20.77,6 20.37,5.61C19.96,5.22 19.5,5.03 18.96,5.03H16L12,1L7.96,5.03H5Z", "image frame"), + ("camera-outline", "M20,4H16.83L15,2H9L7.17,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6A2,2 0 0,0 20,4M20,18H4V6H8.05L9.88,4H14.12L15.95,6H20V18M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15Z", "camera"), + ("camera-iris", "M13.73,15L9.83,21.76C10.53,21.91 11.25,22 12,22C14.4,22 16.6,21.15 18.32,19.75L14.66,13.4M2.46,15C3.38,17.92 5.61,20.26 8.45,21.34L12.12,15M8.54,12L4.64,5.25C3,7 2,9.39 2,12C2,12.68 2.07,13.35 2.2,14H9.69M21.8,10H14.31L14.6,10.5L19.36,18.75C21,16.97 22,14.6 22,12C22,11.31 21.93,10.64 21.8,10M21.54,9C20.62,6.07 18.39,3.74 15.55,2.66L11.88,9M9.4,10.5L14.17,2.24C13.47,2.09 12.75,2 12,2C9.6,2 7.4,2.84 5.68,4.25L9.34,10.6L9.4,10.5Z", "camera iris photo aperture"), + ("crop", "M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17M17,15H19V7C19,5.89 18.1,5 17,5H9V7H17V15Z", "crop"), + ("crop-free", "M19,3H15V5H19V9H21V5C21,3.89 20.1,3 19,3M19,19H15V21H19A2,2 0 0,0 21,19V15H19M5,15H3V19A2,2 0 0,0 5,21H9V19H5M3,5V9H5V5H9V3H5A2,2 0 0,0 3,5Z", "crop free trim borders transparent edges"), + ("crop-square", "M18,18H6V6H18M18,4H6A2,2 0 0,0 4,6V18A2,2 0 0,0 6,20H18A2,2 0 0,0 20,18V6C20,4.89 19.1,4 18,4Z", "crop square"), + ("crop-landscape", "M19,17H5V7H19M19,5H5A2,2 0 0,0 3,7V17A2,2 0 0,0 5,19H19A2,2 0 0,0 21,17V7C21,5.89 20.1,5 19,5Z", "crop landscape"), + ("crop-portrait", "M17,19H7V5H17M17,3H7A2,2 0 0,0 5,5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V5C19,3.89 18.1,3 17,3Z", "crop portrait"), + ("resize", "M10.59,12L14.59,8H11V6H18V13H16V9.41L12,13.41V16H20V4H8V12H10.59M22,2V18H12V22H2V12H6V2H22M10,14H4V20H10V14Z", "resize half size scale smaller"), + ("aspect-ratio", "M19,12H17V15H14V17H19V12M7,9H10V7H5V12H7V9M21,3H3A2,2 0 0,0 1,5V19A2,2 0 0,0 3,21H21A2,2 0 0,0 23,19V5A2,2 0 0,0 21,3M21,19H3V5H21V19Z", "aspect ratio"), + ("arrow-expand-all", "M9.5,13.09L10.91,14.5L6.41,19H10V21H3V14H5V17.59L9.5,13.09M10.91,9.5L9.5,10.91L5,6.41V10H3V3H10V5H6.41L10.91,9.5M14.5,13.09L19,17.59V14H21V21H14V19H17.59L13.09,14.5L14.5,13.09M13.09,9.5L17.59,5H14V3H21V10H19V6.41L14.5,10.91L13.09,9.5Z", "arrow expand all enlarge bigger"), + ("arrow-collapse-all", "M19.5,3.09L20.91,4.5L16.41,9H20V11H13V4H15V7.59L19.5,3.09M20.91,19.5L19.5,20.91L15,16.41V20H13V13H20V15H16.41L20.91,19.5M4.5,3.09L9,7.59V4H11V11H4V9H7.59L3.09,4.5L4.5,3.09M3.09,19.5L7.59,15H4V13H11V20H9V16.41L4.5,20.91L3.09,19.5Z", "arrow collapse all compress shrink smaller"), + ("rotate-left", "M13,4.07V1L8.45,5.55L13,10V6.09C15.84,6.57 18,9.03 18,12C18,14.97 15.84,17.43 13,17.91V19.93C16.95,19.44 20,16.08 20,12C20,7.92 16.95,4.56 13,4.07M7.1,18.32C8.26,19.22 9.61,19.76 11,19.93V17.9C10.13,17.75 9.29,17.41 8.54,16.87L7.1,18.32M6.09,13H4.07C4.24,14.39 4.79,15.73 5.69,16.89L7.1,15.47C6.58,14.72 6.23,13.88 6.09,13M7.11,8.53L5.7,7.11C4.8,8.27 4.24,9.61 4.07,11H6.09C6.23,10.13 6.58,9.28 7.11,8.53Z", "rotate left"), + ("rotate-right", "M16.89,15.5L18.31,16.89C19.21,15.73 19.76,14.39 19.93,13H17.91C17.77,13.87 17.43,14.72 16.89,15.5M13,17.9V19.92C14.39,19.75 15.74,19.21 16.9,18.31L15.46,16.87C14.71,17.41 13.87,17.76 13,17.9M19.93,11C19.76,9.61 19.21,8.27 18.31,7.11L16.89,8.53C17.43,9.28 17.77,10.13 17.91,11M15.55,5.55L11,1V4.07C7.06,4.56 4,7.92 4,12C4,16.08 7.05,19.44 11,19.93V17.91C8.16,17.43 6,14.97 6,12C6,9.03 8.16,6.57 11,6.09V10L15.55,5.55Z", "rotate right"), + ("rotate-360", "M12 7C6.5 7 2 9.2 2 12C2 14.2 4.9 16.1 9 16.8V20L13 16L9 12V14.7C5.8 14.1 4 12.8 4 12C4 10.9 7 9 12 9S20 10.9 20 12C20 12.7 18.5 13.9 16 14.5V16.6C19.5 15.8 22 14.1 22 12C22 9.2 17.5 7 12 7Z", "rotate 360 180"), + ("flip-horizontal", "M15 21H17V19H15M19 9H21V7H19M3 5V19C3 20.1 3.9 21 5 21H9V19H5V5H9V3H5C3.9 3 3 3.9 3 5M19 3V5H21C21 3.9 20.1 3 19 3M11 23H13V1H11M19 17H21V15H19M15 5H17V3H15M19 13H21V11H19M19 21C20.1 21 21 20.1 21 19H19Z", "flip horizontal"), + ("flip-vertical", "M3 15V17H5V15M15 19V21H17V19M19 3H5C3.9 3 3 3.9 3 5V9H5V5H19V9H21V5C21 3.9 20.1 3 19 3M21 19H19V21C20.1 21 21 20.1 21 19M1 11V13H23V11M7 19V21H9V19M19 15V17H21V15M11 19V21H13V19M3 19C3 20.1 3.9 21 5 21V19Z", "flip vertical"), + ("fit-to-page-outline", "M20,2H4C2.89,2 2,2.89 2,4V20C2,21.11 2.89,22 4,22H20C21.11,22 22,21.11 22,20V4C22,2.89 21.11,2 20,2M20,20H4V4H20M13,8V10H11V8H9L12,5L15,8M16,15V13H14V11H16V9L19,12M10,13H8V15L5,12L8,9V11H10M15,16L12,19L9,16H11V14H13V16", "fit to page"), + ("vector-square", "M2,2H8V4H16V2H22V8H20V16H22V22H16V20H8V22H2V16H4V8H2V2M16,8V6H8V8H6V16H8V18H16V16H18V8H16M4,4V6H6V4H4M18,4V6H20V4H18M4,18V20H6V18H4M18,18V20H20V18H18Z", "vector square svg eps"), + ("vector-curve", "M18.5,2A1.5,1.5 0 0,1 20,3.5A1.5,1.5 0 0,1 18.5,5C18.27,5 18.05,4.95 17.85,4.85L14.16,8.55L14.5,9C16.69,7.74 19.26,7 22,7L23,7.03V9.04L22,9C19.42,9 17,9.75 15,11.04A3.96,3.96 0 0,1 11.04,15C9.75,17 9,19.42 9,22L9.04,23H7.03L7,22C7,19.26 7.74,16.69 9,14.5L8.55,14.16L4.85,17.85C4.95,18.05 5,18.27 5,18.5A1.5,1.5 0 0,1 3.5,20A1.5,1.5 0 0,1 2,18.5A1.5,1.5 0 0,1 3.5,17C3.73,17 3.95,17.05 4.15,17.15L7.84,13.45C7.31,12.78 7,11.92 7,11A4,4 0 0,1 11,7C11.92,7 12.78,7.31 13.45,7.84L17.15,4.15C17.05,3.95 17,3.73 17,3.5A1.5,1.5 0 0,1 18.5,2M11,9A2,2 0 0,0 9,11A2,2 0 0,0 11,13A2,2 0 0,0 13,11A2,2 0 0,0 11,9Z", "vector curve trace svg"), + ("draw", "M9.75 20.85C11.53 20.15 11.14 18.22 10.24 17C9.35 15.75 8.12 14.89 6.88 14.06C6 13.5 5.19 12.8 4.54 12C4.26 11.67 3.69 11.06 4.27 10.94C4.86 10.82 5.88 11.4 6.4 11.62C7.31 12 8.21 12.44 9.05 12.96L10.06 11.26C8.5 10.23 6.5 9.32 4.64 9.05C3.58 8.89 2.46 9.11 2.1 10.26C1.78 11.25 2.29 12.25 2.87 13.03C4.24 14.86 6.37 15.74 7.96 17.32C8.3 17.65 8.71 18.04 8.91 18.5C9.12 18.94 9.07 18.97 8.6 18.97C7.36 18.97 5.81 18 4.8 17.36L3.79 19.06C5.32 20 7.88 21.47 9.75 20.85M20.84 5.25C21.06 5.03 21.06 4.67 20.84 4.46L19.54 3.16C19.33 2.95 18.97 2.95 18.76 3.16L17.74 4.18L19.82 6.26M11 10.92V13H13.08L19.23 6.85L17.15 4.77L11 10.92Z", "draw line art sketch pen"), + ("call-merge", "M17,20.41L18.41,19L15,15.59L13.59,17M7.5,8H11V13.59L5.59,19L7,20.41L13,14.41V8H16.5L12,3.5", "call merge join combine"), + ("call-split", "M14,4L16.29,6.29L13.41,9.17L14.83,10.59L17.71,7.71L20,10V4M10,4H4V10L6.29,7.71L11,12.41V20H13V11.59L7.71,6.29", "call split separate"), + ("view-sequential-outline", "M3 5V19H21V5H3M19 7V9H5V7H19M19 11V13H5V11H19M5 17V15H19V17H5Z", "view sequential long image stitch"), + ("text-recognition", "M2 4C2 2.9 2.9 2 4 2H8V4H4V8H2V4M22 20C22 21.11 21.11 22 20 22H16V20H20V16H22V20M4 22C2.9 22 2 21.11 2 20V16H4V20H8V22H4M20 2C21.11 2 22 2.9 22 4V8H20V4H16V2H20M9 7V9H11V17H13V9H15V7H9Z", "text recognition ocr searchable"), + ("presentation", "M2,3H10A2,2 0 0,1 12,1A2,2 0 0,1 14,3H22V5H21V16H15.25L17,22H15L13.25,16H10.75L9,22H7L8.75,16H3V5H2V3M5,5V14H19V5H5Z", "presentation slides pptx"), + ("book-open-variant", "M12 21.5C10.65 20.65 8.2 20 6.5 20C4.85 20 3.15 20.3 1.75 21.05C1.65 21.1 1.6 21.1 1.5 21.1C1.25 21.1 1 20.85 1 20.6V6C1.6 5.55 2.25 5.25 3 5C4.11 4.65 5.33 4.5 6.5 4.5C8.45 4.5 10.55 4.9 12 6C13.45 4.9 15.55 4.5 17.5 4.5C18.67 4.5 19.89 4.65 21 5C21.75 5.25 22.4 5.55 23 6V20.6C23 20.85 22.75 21.1 22.5 21.1C22.4 21.1 22.35 21.1 22.25 21.05C20.85 20.3 19.15 20 17.5 20C15.8 20 13.35 20.65 12 21.5M12 8V19.5C13.35 18.65 15.8 18 17.5 18C18.7 18 19.9 18.15 21 18.5V7C19.9 6.65 18.7 6.5 17.5 6.5C15.8 6.5 13.35 7.15 12 8M13 11.5C14.11 10.82 15.6 10.5 17.5 10.5C18.41 10.5 19.26 10.59 20 10.78V9.23C19.13 9.08 18.29 9 17.5 9C15.73 9 14.23 9.28 13 9.84V11.5M17.5 11.67C15.79 11.67 14.29 11.93 13 12.46V14.15C14.11 13.5 15.6 13.16 17.5 13.16C18.54 13.16 19.38 13.24 20 13.4V11.9C19.13 11.74 18.29 11.67 17.5 11.67M20 14.57C19.13 14.41 18.29 14.33 17.5 14.33C15.67 14.33 14.17 14.6 13 15.13V16.82C14.11 16.16 15.6 15.83 17.5 15.83C18.54 15.83 19.38 15.91 20 16.07V14.57Z", "book open"), + ("book-open-page-variant-outline", "M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z", "book open page"), + ("printer-outline", "M19 8C20.66 8 22 9.34 22 11V17H18V21H6V17H2V11C2 9.34 3.34 8 5 8H6V3H18V8H19M8 5V8H16V5H8M16 19V15H8V19H16M18 15H20V11C20 10.45 19.55 10 19 10H5C4.45 10 4 10.45 4 11V15H6V13H18V15M19 11.5C19 12.05 18.55 12.5 18 12.5C17.45 12.5 17 12.05 17 11.5C17 10.95 17.45 10.5 18 10.5C18.55 10.5 19 10.95 19 11.5Z", "printer"), + ("pencil-outline", "M14.06,9L15,9.94L5.92,19H5V18.08L14.06,9M17.66,3C17.41,3 17.15,3.1 16.96,3.29L15.13,5.12L18.88,8.87L20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18.17,3.09 17.92,3 17.66,3M14.06,6.19L3,17.25V21H6.75L17.81,9.94L14.06,6.19Z", "pencil edit"), + ("signature-freehand", "M22,22H2V20H22V22M6.2,17.3L5.5,18L4.1,16.6L2.7,18L2,17.3L3.4,15.9L2,14.5L2.7,13.8L4.1,15.2L5.5,13.8L6.2,14.5L4.8,15.9L6.2,17.3M16.22,14.43C16.22,13.85 15.5,13.2 14.06,12.46C12.23,11.54 11,10.79 10.36,10.24C9.71,9.68 9.39,9.06 9.39,8.37C9.39,6.59 10.3,5.12 12.12,3.95C13.94,2.78 15.43,2.19 16.57,2.19C17.31,2.19 17.85,2.32 18.18,2.58C18.5,2.83 18.68,3.27 18.68,3.9C18.68,4.18 18.56,4.42 18.31,4.63C18.07,4.83 17.87,4.93 17.74,4.93C17.63,4.93 17.43,4.83 17.13,4.64L16.55,4.38C16.08,4.38 15.14,4.71 13.71,5.38C12.29,6.04 11.58,6.79 11.58,7.63C11.58,8.14 11.82,8.6 12.32,9C12.82,9.42 13.71,9.93 15,10.53C16.03,11 16.86,11.5 17.5,12.07C18.1,12.61 18.41,13.25 18.41,14C18.41,15.34 17.47,16.41 15.58,17.17C13.7,17.94 11.9,18.32 10.19,18.32C8.75,18.32 8,17.83 8,16.86C8,16.5 8.19,16.27 8.5,16.11C8.83,15.95 9.16,15.87 9.5,15.87L10.25,16L10.97,16.13C11.95,16.13 13,15.97 14.13,15.64C15.26,15.32 15.96,14.91 16.22,14.43Z", "signature freehand"), + ("video-outline", "M15,8V16H5V8H15M16,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16A1,1 0 0,0 17,17V13.5L21,17.5V6.5L17,10.5V7A1,1 0 0,0 16,6Z", "video"), + ("movie-open-outline", "M20.84 2.18L16.91 2.96L19.65 6.5L21.62 6.1L20.84 2.18M13.97 3.54L12 3.93L14.75 7.46L16.71 7.07L13.97 3.54M9.07 4.5L7.1 4.91L9.85 8.44L11.81 8.05L9.07 4.5M4.16 5.5L3.18 5.69C2.1 5.9 1.39 6.96 1.61 8.04L2 10L6.9 9.03L4.16 5.5M20 12V20H4V12H20M22 10H2V20C2 21.11 2.9 22 4 22H20C21.11 22 22 21.11 22 20V10Z", "movie open"), + ("filmstrip", "M18,9H16V7H18M18,13H16V11H18M18,17H16V15H18M8,9H6V7H8M8,13H6V11H8M8,17H6V15H8M18,3V5H16V3H8V5H6V3H4V21H6V19H8V21H16V19H18V21H20V3H18Z", "filmstrip"), + ("filmstrip-box-multiple", "M4,6H2V20A2,2 0 0,0 4,22H18V20H4V6M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M10,15H8V13H10V15M10,11H8V9H10V11M10,7H8V5H10V7M20,15H18V13H20V15M20,11H18V9H20V11M20,7H18V5H20V7Z", "filmstrip multiple merge videos"), + ("motion-play-outline", "M10 16.5L16 12L10 7.5M22 12C22 6.46 17.54 2 12 2C10.83 2 9.7 2.19 8.62 2.56L9.32 4.5C10.17 4.16 11.06 3.97 12 3.97C16.41 3.97 20.03 7.59 20.03 12C20.03 16.41 16.41 20.03 12 20.03C7.59 20.03 3.97 16.41 3.97 12C3.97 11.06 4.16 10.12 4.5 9.28L2.56 8.62C2.19 9.7 2 10.83 2 12C2 17.54 6.46 22 12 22C17.54 22 22 17.54 22 12M5.47 3.97C6.32 3.97 7 4.68 7 5.47C7 6.32 6.32 7 5.47 7C4.68 7 3.97 6.32 3.97 5.47C3.97 4.68 4.68 3.97 5.47 3.97Z", "motion play live photo"), + ("content-cut", "M19,3L13,9L15,11L22,4V3M12,12.5A0.5,0.5 0 0,1 11.5,12A0.5,0.5 0 0,1 12,11.5A0.5,0.5 0 0,1 12.5,12A0.5,0.5 0 0,1 12,12.5M6,20A2,2 0 0,1 4,18C4,16.89 4.9,16 6,16A2,2 0 0,1 8,18C8,19.11 7.1,20 6,20M6,8A2,2 0 0,1 4,6C4,4.89 4.9,4 6,4A2,2 0 0,1 8,6C8,7.11 7.1,8 6,8M9.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.64Z", "content cut scissors clip trim"), + ("play-circle-outline", "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z", "play circle"), + ("music-note-outline", "M12 3V13.55A4 4 0 1 0 14 17V7H18V3M10 19A2 2 0 1 1 12 17A2 2 0 0 1 10 19Z", "music note"), + ("playlist-music-outline", "M15,6V8H3V6H15M15,10V12H3V10H15M3,16V14H11V16H3M17,6H22V8H19V17A3,3 0 0,1 16,20A3,3 0 0,1 13,17A3,3 0 0,1 16,14C16.35,14 16.69,14.07 17,14.18V6M16,16A1,1 0 0,0 15,17A1,1 0 0,0 16,18A1,1 0 0,0 17,17A1,1 0 0,0 16,16Z", "playlist music merge audio songs"), + ("microphone-outline", "M17.3,11C17.3,14 14.76,16.1 12,16.1C9.24,16.1 6.7,14 6.7,11H5C5,14.41 7.72,17.23 11,17.72V21H13V17.72C16.28,17.23 19,14.41 19,11M10.8,4.9C10.8,4.24 11.34,3.7 12,3.7C12.66,3.7 13.2,4.24 13.2,4.9L13.19,11.1C13.19,11.76 12.66,12.3 12,12.3C11.34,12.3 10.8,11.76 10.8,11.1M12,14A3,3 0 0,0 15,11V5A3,3 0 0,0 12,2A3,3 0 0,0 9,5V11A3,3 0 0,0 12,14Z", "microphone"), + ("volume-high", "M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.84 14,18.7V20.77C18,19.86 21,16.28 21,12C21,7.72 18,4.14 14,3.23M16.5,12C16.5,10.23 15.5,8.71 14,7.97V16C15.5,15.29 16.5,13.76 16.5,12M3,9V15H7L12,20V4L7,9H3Z", "volume high"), + ("volume-off", "M12,4L9.91,6.09L12,8.18M4.27,3L3,4.27L7.73,9H3V15H7L12,20V13.27L16.25,17.53C15.58,18.04 14.83,18.46 14,18.7V20.77C15.38,20.45 16.63,19.82 17.68,18.96L19.73,21L21,19.73L12,10.73M19,12C19,12.94 18.8,13.82 18.46,14.64L19.97,16.15C20.62,14.91 21,13.5 21,12C21,7.72 18,4.14 14,3.23V5.29C16.89,6.15 19,8.83 19,12M16.5,12C16.5,10.23 15.5,8.71 14,7.97V10.18L16.45,12.63C16.5,12.43 16.5,12.21 16.5,12Z", "volume off"), + ("headphones", "M12,1C7,1 3,5 3,10V17A3,3 0 0,0 6,20H9V12H5V10A7,7 0 0,1 12,3A7,7 0 0,1 19,10V12H15V20H18A3,3 0 0,0 21,17V10C21,5 16.97,1 12,1Z", "headphones"), + ("subtitles-outline", "M20,4A2,2 0 0,1 22,6V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V6A2,2 0 0,1 4,4H20M20,18V6H4V18H20M6,10H8V12H6V10M6,14H14V16H6V14M16,14H18V16H16V14M10,10H18V12H10V10Z", "subtitles captions"), + ("speedometer", "M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z", "speedometer"), + ("format-text", "M18.5,4L19.66,8.35L18.7,8.61C18.25,7.74 17.79,6.87 17.26,6.43C16.73,6 16.11,6 15.5,6H13V16.5C13,17 13,17.5 13.33,17.75C13.67,18 14.33,18 15,18V19H9V18C9.67,18 10.33,18 10.67,17.75C11,17.5 11,17 11,16.5V6H8.5C7.89,6 7.27,6 6.74,6.43C6.21,6.87 5.75,7.74 5.3,8.61L4.34,8.35L5.5,4H18.5Z", "format text"), + ("format-font", "M17,8H20V20H21V21H17V20H18V17H14L12.5,20H14V21H10V20H11L17,8M18,9L14.5,16H18V9M5,3H10C11.11,3 12,3.89 12,5V16H9V11H6V16H3V5C3,3.89 3.89,3 5,3M6,5V9H9V5H6Z", "format font typeface"), + ("code-json", "M5,3H7V5H5V10A2,2 0 0,1 3,12A2,2 0 0,1 5,14V19H7V21H5C3.93,20.73 3,20.1 3,19V15A2,2 0 0,0 1,13H0V11H1A2,2 0 0,0 3,9V5A2,2 0 0,1 5,3M19,3A2,2 0 0,1 21,5V9A2,2 0 0,0 23,11H24V13H23A2,2 0 0,0 21,15V19A2,2 0 0,1 19,21H17V19H19V14A2,2 0 0,1 21,12A2,2 0 0,1 19,10V5H17V3H19M12,15A1,1 0 0,1 13,16A1,1 0 0,1 12,17A1,1 0 0,1 11,16A1,1 0 0,1 12,15M8,15A1,1 0 0,1 9,16A1,1 0 0,1 8,17A1,1 0 0,1 7,16A1,1 0 0,1 8,15M16,15A1,1 0 0,1 17,16A1,1 0 0,1 16,17A1,1 0 0,1 15,16A1,1 0 0,1 16,15Z", "code json data"), + ("code-braces", "M8,3A2,2 0 0,0 6,5V9A2,2 0 0,1 4,11H3V13H4A2,2 0 0,1 6,15V19A2,2 0 0,0 8,21H10V19H8V14A2,2 0 0,0 6,12A2,2 0 0,0 8,10V5H10V3M16,3A2,2 0 0,1 18,5V9A2,2 0 0,0 20,11H21V13H20A2,2 0 0,0 18,15V19A2,2 0 0,1 16,21H14V19H16V14A2,2 0 0,1 18,12A2,2 0 0,1 16,10V5H14V3H16Z", "code braces"), + ("language-markdown-outline", "M20.56 18H3.44C2.65 18 2 17.37 2 16.59V7.41C2 6.63 2.65 6 3.44 6H20.56C21.35 6 22 6.63 22 7.41V16.59C22 17.37 21.35 18 20.56 18M3.44 6.94C3.18 6.94 2.96 7.15 2.96 7.41V16.6C2.96 16.85 3.18 17.06 3.44 17.06H20.56C20.82 17.06 21.04 16.85 21.04 16.6V7.41C21.04 7.15 20.82 6.94 20.56 6.94H3.44M4.89 15.19V8.81H6.81L8.73 11.16L10.65 8.81H12.58V15.19H10.65V11.53L8.73 13.88L6.81 11.53V15.19H4.89M16.9 15.19L14 12.09H15.94V8.81H17.86V12.09H19.79L16.9 15.19", "language markdown"), + ("language-html5", "M12,17.56L16.07,16.43L16.62,10.33H9.38L9.2,8.3H16.8L17,6.31H7L7.56,12.32H14.45L14.22,14.9L12,15.5L9.78,14.9L9.64,13.24H7.64L7.93,16.43L12,17.56M4.07,3H19.93L18.5,19.2L12,21L5.5,19.2L4.07,3Z", "language html5"), + ("translate", "M12.87,15.07L10.33,12.56L10.36,12.53C12.1,10.59 13.34,8.36 14.07,6H17V4H10V2H8V4H1V6H12.17C11.5,7.92 10.44,9.75 9,11.35C8.07,10.32 7.3,9.19 6.69,8H4.69C5.42,9.63 6.42,11.17 7.67,12.56L2.58,17.58L4,19L9,14L12.11,17.11L12.87,15.07M18.5,10H16.5L12,22H14L15.12,19H19.87L21,22H23L18.5,10M15.88,17L17.5,12.67L19.12,17H15.88Z", "translate"), + ("tag-outline", "M21.41 11.58L12.41 2.58A2 2 0 0 0 11 2H4A2 2 0 0 0 2 4V11A2 2 0 0 0 2.59 12.42L11.59 21.42A2 2 0 0 0 13 22A2 2 0 0 0 14.41 21.41L21.41 14.41A2 2 0 0 0 22 13A2 2 0 0 0 21.41 11.58M13 20L4 11V4H11L20 13M6.5 5A1.5 1.5 0 1 1 5 6.5A1.5 1.5 0 0 1 6.5 5Z", "tag"), + ("tag-remove-outline", "M6.5 5A1.5 1.5 0 1 0 8 6.5A1.5 1.5 0 0 0 6.5 5M6.5 5A1.5 1.5 0 1 0 8 6.5A1.5 1.5 0 0 0 6.5 5M21.41 11.58L12.41 2.58A2 2 0 0 0 11 2H4A2 2 0 0 0 2 4V11A2 2 0 0 0 2.59 12.42L3 12.82A5.62 5.62 0 0 1 5.08 12.08L4 11V4H11L20 13L13 20L11.92 18.92A5.57 5.57 0 0 1 11.18 21L11.59 21.41A2 2 0 0 0 13 22A2 2 0 0 0 14.41 21.41L21.41 14.41A2 2 0 0 0 22 13A2 2 0 0 0 21.41 11.58M6.5 5A1.5 1.5 0 1 0 8 6.5A1.5 1.5 0 0 0 6.5 5M8.12 21.54L6 19.41L3.88 21.54L2.46 20.12L4.59 18L2.46 15.88L3.88 14.46L6 16.59L8.12 14.46L9.54 15.88L7.41 18L9.54 20.12Z", "tag remove metadata exif strip privacy"), + ("card-account-details-outline", "M22,3H2C0.91,3.04 0.04,3.91 0,5V19C0.04,20.09 0.91,20.96 2,21H22C23.09,20.96 23.96,20.09 24,19V5C23.96,3.91 23.09,3.04 22,3M22,19H2V5H22V19M14,17V15.75C14,14.09 10.66,13.25 9,13.25C7.34,13.25 4,14.09 4,15.75V17H14M9,7A2.5,2.5 0 0,0 6.5,9.5A2.5,2.5 0 0,0 9,12A2.5,2.5 0 0,0 11.5,9.5A2.5,2.5 0 0,0 9,7M14,7V8H20V7H14M14,9V10H20V9H14M14,11V12H18V11H14", "card account details contact vcard"), + ("calendar-month-outline", "M7 11H9V13H7V11M21 5V19C21 20.11 20.11 21 19 21H5C3.89 21 3 20.1 3 19V5C3 3.9 3.9 3 5 3H6V1H8V3H16V1H18V3H19C20.11 3 21 3.9 21 5M5 7H19V5H5V7M19 19V9H5V19H19M15 13V11H17V13H15M11 13V11H13V13H11M7 15H9V17H7V15M15 17V15H17V17H15M11 17V15H13V17H11Z", "calendar month event ics"), + ("email-outline", "M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z", "email"), + ("share-variant-outline", "M18 16.08C17.24 16.08 16.56 16.38 16.04 16.85L8.91 12.7C8.96 12.47 9 12.24 9 12S8.96 11.53 8.91 11.3L15.96 7.19C16.5 7.69 17.21 8 18 8C19.66 8 21 6.66 21 5S19.66 2 18 2 15 3.34 15 5C15 5.24 15.04 5.47 15.09 5.7L8.04 9.81C7.5 9.31 6.79 9 6 9C4.34 9 3 10.34 3 12S4.34 15 6 15C6.79 15 7.5 14.69 8.04 14.19L15.16 18.34C15.11 18.55 15.08 18.77 15.08 19C15.08 20.61 16.39 21.91 18 21.91S20.92 20.61 20.92 19C20.92 17.39 19.61 16.08 18 16.08M18 4C18.55 4 19 4.45 19 5S18.55 6 18 6 17 5.55 17 5 17.45 4 18 4M6 13C5.45 13 5 12.55 5 12S5.45 11 6 11 7 11.45 7 12 6.55 13 6 13M18 20C17.45 20 17 19.55 17 19S17.45 18 18 18 19 18.45 19 19 18.55 20 18 20Z", "share airdrop send"), + ("content-copy", "M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z", "content copy duplicate"), + ("content-paste", "M19,20H5V4H7V7H17V4H19M12,2A1,1 0 0,1 13,3A1,1 0 0,1 12,4A1,1 0 0,1 11,3A1,1 0 0,1 12,2M19,2H14.82C14.4,0.84 13.3,0 12,0C10.7,0 9.6,0.84 9.18,2H5A2,2 0 0,0 3,4V20A2,2 0 0,0 5,22H19A2,2 0 0,0 21,20V4A2,2 0 0,0 19,2Z", "content paste"), + ("link-variant", "M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z", "link path url"), + ("console", "M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z", "console terminal shell command"), + ("send-outline", "M4 6.03L11.5 9.25L4 8.25L4 6.03M11.5 14.75L4 17.97V15.75L11.5 14.75M2 3L2 10L17 12L2 14L2 21L23 12L2 3Z", "send"), + ("cloud-upload-outline", "M6.5 20Q4.22 20 2.61 18.43 1 16.85 1 14.58 1 12.63 2.17 11.1 3.35 9.57 5.25 9.15 5.88 6.85 7.75 5.43 9.63 4 12 4 14.93 4 16.96 6.04 19 8.07 19 11 20.73 11.2 21.86 12.5 23 13.78 23 15.5 23 17.38 21.69 18.69 20.38 20 18.5 20H13Q12.18 20 11.59 19.41 11 18.83 11 18V12.85L9.4 14.4L8 13L12 9L16 13L14.6 14.4L13 12.85V18H18.5Q19.55 18 20.27 17.27 21 16.55 21 15.5 21 14.45 20.27 13.73 19.55 13 18.5 13H17V11Q17 8.93 15.54 7.46 14.08 6 12 6 9.93 6 8.46 7.46 7 8.93 7 11H6.5Q5.05 11 4.03 12.03 3 13.05 3 14.5 3 15.95 4.03 17 5.05 18 6.5 18H9V20M12 13Z", "cloud upload"), + ("download-outline", "M13,5V11H14.17L12,13.17L9.83,11H11V5H13M15,3H9V9H5L12,16L19,9H15V3M19,18H5V20H19V18Z", "download"), + ("upload-outline", "M9,10V16H15V10H19L12,3L5,10H9M12,5.8L14.2,8H13V14H11V8H9.8L12,5.8M19,18H5V20H19V18Z", "upload"), + ("export-variant", "M12,1L8,5H11V14H13V5H16M18,23H6C4.89,23 4,22.1 4,21V9A2,2 0 0,1 6,7H9V9H6V21H18V9H15V7H18A2,2 0 0,1 20,9V21A2,2 0 0,1 18,23Z", "export"), + ("lightning-bolt-outline", "M11 9.47V11H14.76L13 14.53V13H9.24L11 9.47M13 1L6 15H11V23L18 9H13V1Z", "lightning bolt actions quick fast"), + ("star-outline", "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", "star"), + ("heart-outline", "M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z", "heart"), + ("bookmark-outline", "M17,18L12,15.82L7,18V5H17M17,3H7A2,2 0 0,0 5,5V21L12,18L19,21V5C19,3.89 18.1,3 17,3Z", "bookmark"), + ("flag-outline", "M12.36,6L12.76,8H18V14H14.64L14.24,12H7V6H12.36M14,4H5V21H7V14H12.6L13,16H20V6H14.4", "flag"), + ("check-circle-outline", "M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z", "check circle"), + ("cog-outline", "M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10M10,22C9.75,22 9.54,21.82 9.5,21.58L9.13,18.93C8.5,18.68 7.96,18.34 7.44,17.94L4.95,18.95C4.73,19.03 4.46,18.95 4.34,18.73L2.34,15.27C2.21,15.05 2.27,14.78 2.46,14.63L4.57,12.97L4.5,12L4.57,11L2.46,9.37C2.27,9.22 2.21,8.95 2.34,8.73L4.34,5.27C4.46,5.05 4.73,4.96 4.95,5.05L7.44,6.05C7.96,5.66 8.5,5.32 9.13,5.07L9.5,2.42C9.54,2.18 9.75,2 10,2H14C14.25,2 14.46,2.18 14.5,2.42L14.87,5.07C15.5,5.32 16.04,5.66 16.56,6.05L19.05,5.05C19.27,4.96 19.54,5.05 19.66,5.27L21.66,8.73C21.79,8.95 21.73,9.22 21.54,9.37L19.43,11L19.5,12L19.43,13L21.54,14.63C21.73,14.78 21.79,15.05 21.66,15.27L19.66,18.73C19.54,18.95 19.27,19.04 19.05,18.95L16.56,17.95C16.04,18.34 15.5,18.68 14.87,18.93L14.5,21.58C14.46,21.82 14.25,22 14,22H10M11.25,4L10.88,6.61C9.68,6.86 8.62,7.5 7.85,8.39L5.44,7.35L4.69,8.65L6.8,10.2C6.4,11.37 6.4,12.64 6.8,13.8L4.68,15.36L5.43,16.66L7.86,15.62C8.63,16.5 9.68,17.14 10.87,17.38L11.24,20H12.76L13.13,17.39C14.32,17.14 15.37,16.5 16.14,15.62L18.57,16.66L19.32,15.36L17.2,13.81C17.6,12.64 17.6,11.37 17.2,10.2L19.31,8.65L18.56,7.35L16.15,8.39C15.38,7.5 14.32,6.86 13.12,6.62L12.75,4H11.25Z", "cog"), + ("magnify", "M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z", "magnify"), + ("eye-outline", "M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9M12,4.5C17,4.5 21.27,7.61 23,12C21.27,16.39 17,19.5 12,19.5C7,19.5 2.73,16.39 1,12C2.73,7.61 7,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C15.76,17.5 19.17,15.36 20.82,12C19.17,8.64 15.76,6.5 12,6.5C8.24,6.5 4.83,8.64 3.18,12Z", "eye"), + ("lock-outline", "M12,17C10.89,17 10,16.1 10,15C10,13.89 10.89,13 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17M18,20V10H6V20H18M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10C4,8.89 4.89,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z", "lock"), + ("shield-outline", "M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z", "shield"), + ("clock-outline", "M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z", "clock"), + ("delete-outline", "M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8,9H16V19H8V9M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z", "delete"), + ("sync", "M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z", "sync"), + ("map-marker-outline", "M12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5M12,2A7,7 0 0,1 19,9C19,14.25 12,22 12,22C12,22 5,14.25 5,9A7,7 0 0,1 12,2M12,4A5,5 0 0,0 7,9C7,10 7,12 12,18.71C17,12 17,10 17,9A5,5 0 0,0 12,4Z", "map marker gps location"), + ("cube-outline", "M21,16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V7.5C3,7.12 3.21,6.79 3.53,6.62L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.79,6.79 21,7.12 21,7.5V16.5M12,4.15L6.04,7.5L12,10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V9.21L13,12.58V19.29L19,15.91Z", "cube 3d model"), + ("drawing", "M8.5,3A5.5,5.5 0 0,1 14,8.5C14,9.83 13.53,11.05 12.74,12H21V21H12V12.74C11.05,13.53 9.83,14 8.5,14A5.5,5.5 0 0,1 3,8.5A5.5,5.5 0 0,1 8.5,3Z", "drawing cad dwg dxf"), + ("application-outline", "M21 2H3C1.9 2 1 2.9 1 4V20C1 21.1 1.9 22 3 22H21C22.1 22 23 21.1 23 20V4C23 2.9 22.1 2 21 2M21 20H3V6H21V20Z", "application icon app window"), + ]; + + /// The icon picker's groups, in order; an icon can be in more than one. + private static readonly (SliceIconCategory Category, string[] Keys)[] CategoryData = + [ + (SliceIconCategory.FilesAndFolders, ["file-outline", "file-multiple-outline", "file-plus-outline", "file-swap-outline", "file-document-outline", "file-document-edit-outline", "file-image-outline", "file-pdf-box", "file-video-outline", "file-music-outline", "file-table-outline", "file-presentation-box", "text-box-outline", "folder-outline", "folder-move-outline", "folder-open-outline", "folder-zip-outline", "package-variant", "zip-box-outline", "archive-outline"]), + (SliceIconCategory.Pictures, ["image-outline", "image-multiple-outline", "image-edit-outline", "image-filter-black-white", "image-filter-vintage", "auto-fix", "blur", "palette-outline", "brush-outline", "eyedropper-variant", "contrast-circle", "brightness-6", "water-outline", "sticker-outline", "image-frame", "camera-outline", "camera-iris"]), + (SliceIconCategory.CropAndTransform, ["crop", "crop-free", "crop-square", "crop-landscape", "crop-portrait", "resize", "aspect-ratio", "arrow-expand-all", "arrow-collapse-all", "rotate-left", "rotate-right", "rotate-360", "flip-horizontal", "flip-vertical", "fit-to-page-outline", "vector-square", "vector-curve", "draw"]), + (SliceIconCategory.PdfAndPages, ["file-pdf-box", "call-merge", "call-split", "view-sequential-outline", "text-recognition", "presentation", "book-open-variant", "book-open-page-variant-outline", "printer-outline", "pencil-outline", "signature-freehand"]), + (SliceIconCategory.VideoAndAudio, ["video-outline", "movie-open-outline", "filmstrip", "filmstrip-box-multiple", "motion-play-outline", "content-cut", "play-circle-outline", "music-note-outline", "playlist-music-outline", "microphone-outline", "volume-high", "volume-off", "headphones", "subtitles-outline", "speedometer"]), + (SliceIconCategory.TextAndData, ["format-text", "format-font", "code-json", "code-braces", "language-markdown-outline", "language-html5", "translate", "tag-outline", "tag-remove-outline", "card-account-details-outline", "calendar-month-outline", "email-outline"]), + (SliceIconCategory.ShareAndMove, ["share-variant-outline", "content-copy", "content-paste", "link-variant", "console", "send-outline", "cloud-upload-outline", "download-outline", "upload-outline", "export-variant"]), + (SliceIconCategory.General, ["lightning-bolt-outline", "star-outline", "heart-outline", "bookmark-outline", "flag-outline", "check-circle-outline", "cog-outline", "magnify", "eye-outline", "lock-outline", "shield-outline", "clock-outline", "delete-outline", "sync", "map-marker-outline", "cube-outline", "drawing", "application-outline"]), + ]; + + /// The built-in presets' icons, by preset id. Plain format conversions have none: their name is the label. + private static readonly Dictionary DefaultData = new() + { + ["half-size"] = "resize", + ["grayscale"] = "image-filter-black-white", + ["remove-background"] = "auto-fix", + ["trim-transparent"] = "crop-free", + ["tool-crop"] = "crop", + ["tool-image-edit"] = "image-edit-outline", + ["tool-hide"] = "blur", + ["tool-sheet-studio"] = "file-document-edit-outline", + ["tool-pdf-crop"] = "crop", + ["tool-pdf-pages"] = "file-swap-outline", + ["tool-pdf-add"] = "file-plus-outline", + ["pdf-rotate-left"] = "rotate-left", + ["pdf-rotate-right"] = "rotate-right", + ["pdf-edit"] = "pencil-outline", + ["rotate-left"] = "rotate-left", + ["rotate-right"] = "rotate-right", + ["rotate-180"] = "rotate-360", + ["flip-horizontal"] = "flip-horizontal", + ["flip-vertical"] = "flip-vertical", + ["rotate"] = "rotate-right", + ["flip"] = "flip-horizontal", + ["merge-pdf"] = "file-multiple-outline", + ["pdf-long-image"] = "view-sequential-outline", + ["pdf-split"] = "call-split", + ["compress-pdf"] = "arrow-collapse-all", + ["searchable-pdf"] = "text-recognition", + ["pdf-slides"] = "presentation", + ["extract-frames-png"] = "filmstrip", + ["extract-frames-jpg"] = "filmstrip", + ["extract-motion"] = "video-outline", + ["live-photo-iphone"] = "motion-play-outline", + ["motion-photo-android"] = "motion-play-outline", + ["tool-clip"] = "content-cut", + ["compress-image"] = "arrow-collapse-all", + ["compress-video"] = "arrow-collapse-all", + ["video-from-images"] = "movie-open-outline", + ["extract-audio"] = "music-note-outline", + ["merge-videos"] = "filmstrip-box-multiple", + ["compress-audio"] = "arrow-collapse-all", + ["merge-audio"] = "playlist-music-outline", + ["remove-metadata"] = "tag-remove-outline", + ["merge-images"] = "image-multiple-outline", + ["extract"] = "package-variant", + ["zip-all"] = "zip-box-outline", + ["extract-subtitles"] = "subtitles-outline", + ["caption-video"] = "microphone-outline", // AI Captions: speech to subtitles + ["trace-svg"] = "vector-curve", + ["trace-line-art"] = "draw", + ["action-share"] = "share-variant-outline", + ["action-copy-to"] = "content-copy", + ["action-move-to"] = "folder-move-outline", + ["action-copy-path"] = "link-variant", + ["action-terminal"] = "console", + ["actions"] = "lightning-bolt-outline", + }; +} diff --git a/src/Filee.App/Controls/SliceIcons.cs b/src/Filee.App/Controls/SliceIcons.cs new file mode 100644 index 000000000..e2b42cbc1 --- /dev/null +++ b/src/Filee.App/Controls/SliceIcons.cs @@ -0,0 +1,97 @@ +// The icons a donut slice can show: a set chosen for file work from Material Design Icons (the set the rest of the app +// uses), each by its own name as key, grouped for the icon picker, and the built-in presets' own icons. The path data +// is in SliceIcons.Data.cs. + +using Avalonia.Media; +using Filee.Core.Presets; + +namespace Filee.App.Controls; + +/// The icon picker's groups. +public enum SliceIconCategory +{ + FilesAndFolders, + Pictures, + CropAndTransform, + PdfAndPages, + VideoAndAudio, + TextAndData, + ShareAndMove, + General, +} + +/// The icons a slice can show (), drawn from 24 × 24 path data. +internal static partial class SliceIcons +{ + /// The side of the square every icon's path data is drawn in. + internal const double Box = 24; + + // Icons a tool shows when its preset has no icon of its own (a copy of a built-in tool, say). + private static readonly Dictionary ToolDefaults = new() + { + [Preset.CropTool] = "crop", + [Preset.ClipTool] = "content-cut", + [Preset.ImageEditTool] = "image-edit-outline", + [Preset.HideTool] = "blur", + }; + + // Built on first use: the data is in another file of this class, and static fields of a class split over files + // are not set up in a known order. + private static Dictionary? _index; + + private static Dictionary Index => + _index ??= Data.Select((icon, i) => (icon.Key, i)).ToDictionary(e => e.Key, e => e.i, StringComparer.Ordinal); + + private static readonly Dictionary Geometries = new(StringComparer.Ordinal); + + /// Every icon's key, in the picker's order. + internal static IEnumerable Keys => Data.Select(icon => icon.Key); + + /// The picker's groups and their icons, in order. + internal static IEnumerable<(SliceIconCategory Category, IReadOnlyList Keys)> Categories => + CategoryData.Select(c => (c.Category, (IReadOnlyList)c.Keys)); + + internal static bool Exists(string? key) => key is not null && Index.ContainsKey(key); + + /// + /// The icon shows when it has none of its own: a built-in preset's (by id), else its tool's, + /// else none. Plain format conversions have none: their name is their label. + /// + internal static string? DefaultFor(Preset preset) => + DefaultData.TryGetValue(preset.Id, out var key) ? key + : preset.IsTool && ToolDefaults.TryGetValue(preset.Tool ?? Preset.CropTool, out var tool) ? tool + : null; + + /// + /// The icon 's slice shows: its own, none for , or (unset, or a + /// key this version doesn't have) its default. + /// + internal static string? For(Preset preset) => + preset.Icon == Preset.NoIcon ? null + : Exists(preset.Icon) ? preset.Icon + : DefaultFor(preset); + + /// The shape of icon in a × square, or null. + internal static Geometry? Geometry(string? key) + { + if (key is null || !Index.TryGetValue(key, out var i)) + return null; + if (!Geometries.TryGetValue(key, out var geometry)) + Geometries[key] = geometry = StreamGeometry.Parse(Data[i].Path); + return geometry; + } + + /// + /// The icons matching : every word of it starts a word of the icon's name or search words. + /// Blank: every icon. + /// + internal static IEnumerable Search(string? text) + { + var wanted = (text ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries); + return Data.Where(icon => + { + var words = icon.Words.Split(' '); + return wanted.All(w => words.Any(word => word.StartsWith(w, StringComparison.OrdinalIgnoreCase))); + }).Select(icon => icon.Key); + } +} diff --git a/src/Filee.App/Controls/SliceLabelLayout.cs b/src/Filee.App/Controls/SliceLabelLayout.cs new file mode 100644 index 000000000..04d9de063 --- /dev/null +++ b/src/Filee.App/Controls/SliceLabelLayout.cs @@ -0,0 +1,624 @@ +// How a slice's label fits its slice: the safe zone it stays inside, its name wrapped at word breaks and drawn straight +// or along the slice, its icon, and the style one ring shows (Show). Nothing is drawn here: DonutMenu draws what this +// lays out, and the tests check the same layouts. +// +// Safe zone → the slice shrunk on all four sides (8 % of its depth, at least 6 px × the donut's scale, plus half +// the gap between slices); a group slice also keeps clear of its chevron. +// One size → every name on a ring is drawn at one size: 13 × Label scale × donut scale, stepped down 0.5 px at +// a time (to 11 px × donut scale, never under 10 px) until every name fits whole; names wrap at word +// breaks (up to 3 lines) and are never split, except Chinese and Japanese. +// Direction → one for the ring: its names are drawn straight, or (where that doesn't fit them all whole) all +// along their slices. +// Show → Auto / Icon & text / Text only / Icon only; one style for the ring's slices that have an icon. +// Auto: icon and name, else name, else icon, each only if every name shows whole. Icon & text: icon +// and name, a name shortened to 60 % of its letters at most, else the icon alone. No stub ("Tri…"). +// Captions → only under a name shown alone, and then on every slice that has one or on none: an icon stands +// in for the format line. + +using System.Globalization; +using Avalonia; +using Avalonia.Media; +using Filee.Core.Presets; +using Filee.Core.Profiles; + +namespace Filee.App.Controls; + +/// +/// One slice's outline in the donut's coordinates (angles clockwise from 12 o'clock, as in ). +/// : how far in from the outer edge something else is drawn (a group's chevron), 0 for none. +/// +internal readonly record struct SliceShape(Point Center, double Inner, double Outer, double Mid, double Span, double Gap, + double OuterKeepOut = 0); + +/// A rectangle turned by (radians, clockwise) about its centre. +internal readonly record struct LabelBox(Point Center, double Width, double Height, double Rotation) +{ + /// Its four corners, in order round it. + internal Point[] Corners() + { + double c = Math.Cos(Rotation), s = Math.Sin(Rotation), w = Width / 2, h = Height / 2; + var (x, y) = (Center.X, Center.Y); + return [new(x - w * c + h * s, y - w * s - h * c), new(x + w * c + h * s, y + w * s - h * c), + new(x + w * c - h * s, y + w * s + h * c), new(x - w * c - h * s, y - w * s + h * c)]; + } +} + +/// +/// The part of a slice its label may use: between and from the +/// donut's centre, and at least from both side edges. +/// +internal readonly record struct SafeZone(Point Center, double Inner, double Outer, double Mid, double HalfSpan, double Side) +{ + /// The inset: this share of the slice's depth, and at least px × the donut's scale. + internal const double MarginShare = 0.08, MinMargin = 6; + + internal static SafeZone Of(SliceShape shape, double scale) + { + var margin = Math.Max(MinMargin * scale, MarginShare * (shape.Outer - shape.Inner)); + return new SafeZone(shape.Center, shape.Inner + margin, shape.Outer - Math.Max(margin, shape.OuterKeepOut), + shape.Mid, shape.Span / 2, margin + shape.Gap / 2); + } + + internal double Depth => Outer - Inner; + + /// The point on the slice's middle line from the donut's centre. + internal Point At(double radius) => DonutGeometry.PointAt(Center, radius, Mid); + + internal bool Contains(Point p) => Contains(p, 0); + + /// True if a circle of round lies inside. + internal bool Contains(Point p, double radius) + { + double dx = p.X - Center.X, dy = p.Y - Center.Y; + var distance = Math.Sqrt(dx * dx + dy * dy); + if (distance - radius < Inner - 1e-9 || distance + radius > Outer + 1e-9) + return false; + if (HalfSpan >= Math.PI) + return true; // one slice round the whole donut: no side edges + var off = Math.Abs(Turn(Math.Atan2(dx, -dy) - Mid)); + // The distance to a side edge's line, as long as the point is on this side of it. + return off < HalfSpan && distance * Math.Sin(Math.Min(HalfSpan - off, Math.PI / 2)) >= Side + radius - 1e-9; + } + + /// + /// True if all of lies inside. The outer circle and the side edges bound a convex area, so + /// its corners tell; the inner circle curves in, so each of its sides must also pass outside that circle. + /// + internal bool Contains(LabelBox box) + { + var corners = box.Corners(); + if (!corners.All(Contains)) + return false; + for (var i = 0; i < corners.Length; i++) + if (DistanceTo(corners[i], corners[(i + 1) % corners.Length]) < Inner - 1e-9) + return false; + return true; + } + + // The distance from the donut's centre to the nearest point of the segment from a to b. + private double DistanceTo(Point a, Point b) + { + var (dx, dy) = (b.X - a.X, b.Y - a.Y); + var length = dx * dx + dy * dy; + var t = length == 0 ? 0 : Math.Clamp(((Center.X - a.X) * dx + (Center.Y - a.Y) * dy) / length, 0, 1); + var (x, y) = (a.X + t * dx - Center.X, a.Y + t * dy - Center.Y); + return Math.Sqrt(x * x + y * y); + } + + private static double Turn(double a) + { + a %= 2 * Math.PI; + return a > Math.PI ? a - 2 * Math.PI : a < -Math.PI ? a + 2 * Math.PI : a; + } +} + +/// The fonts and reading direction labels are measured and drawn with. +internal sealed record LabelFonts(Typeface Bold, Typeface Regular, FlowDirection Direction); + +/// +/// A slice's name (and caption), laid out: at , centred on +/// and turned by . is false for a name shortened with "…". +/// +internal sealed record LabelText(IReadOnlyList Lines, IReadOnlyList LineHeights, double Size, + string? Caption, double CaptionSize, double CaptionHeight, double Width, double Height, Point Anchor, double Rotation, + bool Whole) +{ + internal LabelBox Box => new(Anchor, Width, Height, Rotation); +} + +/// +/// Which way a name runs: straight, along its slice (out from the centre), or either, straight first. A ring's names all +/// run one way. +/// +internal enum TextWay { Either, Straight, Along } + +/// What a slice shows: nothing, its name, its icon above its name, or its icon. +internal enum SliceLabelKind { None, Text, IconAndText, Icon } + +/// One slice's laid-out label, ready to draw. +internal sealed record SliceLabel(SliceLabelKind Kind, LabelText? Text, string? IconKey, Point IconCenter, double IconSize, + SafeZone Zone) +{ + internal static readonly SliceLabel Empty = new(SliceLabelKind.None, null, null, default, 0, default); + + /// True when the slice's whole name is shown. + internal bool NameWhole => Text?.Whole == true; + + /// How far round an icon's centre it is checked against the zone: the drawn part of a 24 × 24 icon. + internal static double IconReach(double size) => size * IconReachShare; + + internal const double IconReachShare = 0.6; +} + +/// One slice to lay out: its name, caption, icon and Show, and its outline. +internal sealed record SliceLabelInput(string Name, string? Caption, string? IconKey, LabelShow Show, SliceShape Shape); + +/// Lays out the labels of one ring of slices (the donut's face, or a group's ring). +internal sealed class SliceLabelLayout +{ + /// A name's size at Label scale 100 % on a donut of the default size, and the smallest it is drawn. + internal const double NameSize = 13, MinNameSize = 10; + + /// + /// How far a ring's names step down (× the donut's scale, in steps) for every name to fit + /// whole: smaller gets hard to read. + /// + internal const double MinStepNameSize = 11, SizeStep = 0.5; + + /// A caption's size as a share of the name's. + internal const double CaptionShare = 0.8; + + /// The most lines a name wraps to. + internal const int MaxLines = 3; + + /// + /// The share of its letters a shortened name keeps at least (and it never drops a whole word): fewer ("Tri…" for + /// "Trim transparent borders") say nothing. + /// + internal const double MinKeptShare = 0.6; + + /// An icon above a name, and the largest and smallest icon drawn alone, at 100 % and the default size. + internal const double IconWithText = 20, IconAloneMax = 28, IconAloneMin = 16, IconStep = 2, IconGap = 3; + + /// The default donut radius, at which the donut's scale is 1. + internal const double DefaultRadius = 150; + + /// The ways a ring's names are tried, in order: one for every name of the ring. + private static readonly TextWay[] Ways = [TextWay.Straight, TextWay.Along]; + + // Where along a slice's middle line a label is tried: the zone's middle first, then further in and out. + private static readonly double[] Offsets = [0, 0.1, -0.1, 0.2, -0.2, 0.3, -0.3, 0.4, -0.4]; + + private readonly LabelFonts _fonts; + private readonly Dictionary<(string Text, Typeface Face, double Size), Size> _sizes = new(); + private const int MaxCachedSizes = 2048; + + internal SliceLabelLayout(LabelFonts fonts) => _fonts = fonts; + + internal LabelFonts Fonts => _fonts; + + /// The size every name on a ring is drawn at. + internal static double NameSizeFor(double labelScale, double scale) => Math.Max(MinNameSize, NameSize * labelScale * scale); + + internal static double CaptionSizeFor(double nameSize) => Math.Round(nameSize * CaptionShare * 2) / 2; + + /// + /// The sizes a ring's names are tried at, largest first: , then down in + /// steps to × (never under + /// ). Just when it is that small already. + /// + internal static IReadOnlyList SizesFor(double nameSize, double scale) + { + var sizes = new List { nameSize }; + var floor = Math.Max(MinNameSize, MinStepNameSize * scale); + for (var size = Math.Ceiling(nameSize / SizeStep - 1) * SizeStep; size >= floor - 1e-9; size -= SizeStep) + sizes.Add(size); + return sizes; + } + + /// + /// The fewest text elements a shortened keeps: of its letters + /// (spaces not counted), and two letters into its last word at least, so no word is dropped whole. + /// + internal static int MinKept(string name) + { + var elements = TextElements(name.Trim()); + var need = Math.Max(1, (int)Math.Ceiling(MinKeptShare * elements.Count(e => !string.IsNullOrWhiteSpace(e)))); + var lastWord = elements.FindLastIndex(string.IsNullOrWhiteSpace) + 1; + var words = lastWord > 0 ? Math.Min(elements.Count, lastWord + 2) : 0; + var letters = 0; + for (var i = 0; i < elements.Count; i++) + if (!string.IsNullOrWhiteSpace(elements[i]) && ++letters >= need) + return Math.Max(i + 1, words); + return elements.Count; + } + + /// + /// at or, if a slice wouldn't show its whole name or its icon + /// there, at the largest smaller Label scale (in steps, down to 100 %) at + /// which every slice does. For a group's ring, which has no room to grow (yet) with the labels. + /// + internal IReadOnlyList PlanRingToFit(IReadOnlyList slices, double labelScale, double scale, + bool showCaptions) + { + var floor = Math.Min(labelScale, 1); + for (var step = labelScale; ; step = Math.Round(step - ToolbarProfile.LabelScaleStep, 2)) + { + var plan = PlanRing(slices, Math.Max(step, floor), scale, showCaptions); + if (step <= floor + 1e-9 || plan.Select((label, i) => Fits(label, slices[i])).All(f => f)) + return plan; + } + + // Shows what it was asked to: its whole name, or its icon. + static bool Fits(SliceLabel label, SliceLabelInput slice) => + (slice.Name.Length == 0 && slice.IconKey is null) || label.Kind == SliceLabelKind.Icon || + (label.Kind != SliceLabelKind.None && label.NameWhole); + } + + /// + /// The labels of one ring: one style for the slices of each Show that have an icon, one name size and one icon + /// size. Each style is tried at every size of , largest first, and taken only if every slice + /// fits: Auto (whole names only) icon and name, else name, else icon; Icon & text icon and name, whole names first, + /// then names shortened to , else the icon alone; Text only the name, whole first, then + /// shortened to , then as far as it takes (the centre names the slice in full). A slice + /// without an icon (a format) shows its name and never decides the others' style. Captions show under names alone + /// only, on every slice that has one or on none. + /// + internal IReadOnlyList PlanRing(IReadOnlyList slices, double labelScale, double scale, + bool showCaptions) + { + var baseSize = NameSizeFor(labelScale, scale); + var sizes = SizesFor(baseSize, scale); + var k = labelScale * scale; + var zones = slices.Select(s => SafeZone.Of(s.Shape, scale)).ToArray(); + // An icon beside a smaller name shrinks with it, to no less than the smallest icon drawn alone. + double IconFor(double size) => IconWithText * k * Math.Max(IconAloneMin / IconWithText, size / baseSize); + string? CaptionOf(SliceLabelInput s) => showCaptions && !string.IsNullOrEmpty(s.Caption) && + !string.Equals(s.Caption, s.Name, StringComparison.OrdinalIgnoreCase) ? s.Caption : null; + + var result = new SliceLabel?[slices.Count]; + var wantIcon = new bool[slices.Count]; + + // The icon and the name, no caption: the icon stands in for it. + SliceLabel? Both(int i, double size, bool shorten) + { + var s = slices[i]; + var icon = IconFor(size); + var fit = FitIconAndText(zones[i], s.Name, null, size, 0, icon, k, shorten, MinKept(s.Name)); + return fit is { } f ? new SliceLabel(SliceLabelKind.IconAndText, f.Text, s.IconKey, f.Icon, icon, zones[i]) : null; + } + // The name, with its caption when withCaption (null if the caption doesn't fit under it); shortened to minKept + // (a caption never goes with a shortened name). + SliceLabel? Name(int i, double size, bool withCaption, bool shorten, TextWay way, int? minKept = null) + { + var s = slices[i]; + var caption = withCaption ? CaptionOf(s) : null; + var block = FitText(zones[i], s.Name, caption, size, CaptionSizeFor(size), alongToo: true, way); + if (caption is not null && block?.Caption is null) + return null; + if (block is null && shorten && caption is null) + block = FitShortened(zones[i], s.Name, size, alongToo: true, minKept ?? MinKept(s.Name), way); + return block is null ? null : new SliceLabel(SliceLabelKind.Text, block, null, default, 0, zones[i]); + } + // A style for all of group, at the largest size every slice fits; false (nothing set) if none does. A slice + // without an icon shows its name beside the others' icons, shortened if need be. + bool Take(List group, Func fit) + { + foreach (var size in sizes) + { + var plan = group.Select(i => fit(i, size)).ToList(); + if (plan.Any(p => p is null)) + continue; + for (var j = 0; j < group.Count; j++) + result[group[j]] = plan[j]; + return true; + } + return false; + } + // Beside icons over their names (upright), a name without an icon runs straight too. + bool TakeBoth(List group, bool shorten) => group.Any(i => slices[i].IconKey is not null) && + Take(group, (i, size) => slices[i].IconKey is null ? Name(i, size, false, true, TextWay.Straight) : Both(i, size, shorten)); + // One direction for the ring's names: straight through every size, then along the slices. + bool TakeNames(List group, bool shorten) + { + foreach (var way in Ways) + // Captions on every slice that has one, else on none. + foreach (var withCaption in group.Any(i => CaptionOf(slices[i]) is not null) ? new[] { true, false } : new[] { false }) + if (Take(group, (i, size) => Name(i, size, withCaption, shorten || slices[i].IconKey is null, way))) + return true; + return false; + } + // As far as it takes, at the smallest size, still one way for all of them. + void LastResort(List group) + { + foreach (var way in Ways) + { + var plan = group.Select(i => Name(i, sizes[^1], false, true, way, 0)).ToList(); + if (plan.All(p => p is not null) || way == Ways[^1]) + { + for (var j = 0; j < group.Count; j++) + result[group[j]] = plan[j] ?? SliceLabel.Empty; + return; + } + } + } + List Showing(LabelShow show) => Enumerable.Range(0, slices.Count) + .Where(i => slices[i].Show == show && slices[i].Name.Length > 0).ToList(); + void Icons(List group) + { + foreach (var i in group) + wantIcon[i] = slices[i].IconKey is not null; + } + + var auto = Showing(LabelShow.Auto); + if (auto.Count > 0 && !TakeBoth(auto, false) && !TakeNames(auto, false)) + Icons(auto); + var both = Showing(LabelShow.IconAndText); + if (both.Count > 0 && !TakeBoth(both, false) && !TakeBoth(both, true)) + Icons(both); + var names = Showing(LabelShow.TextOnly); + if (names.Count > 0 && !TakeNames(names, false) && !TakeNames(names, true)) + LastResort(names); // the centre names the slice in full + + // What's left: icons (Icon only, or a ring gone to icons) and names of slices without one. + var rest = new List(); + for (var i = 0; i < slices.Count; i++) + { + var s = slices[i]; + if (result[i] is not null || wantIcon[i]) + continue; + if (s.Name.Length == 0 && s.IconKey is null) + result[i] = SliceLabel.Empty; + else if (s.IconKey is not null && (s.Show == LabelShow.IconOnly || s.Name.Length == 0)) + wantIcon[i] = true; + else + rest.Add(i); + } + if (rest.Count > 0 && !TakeNames(rest, false) && !TakeNames(rest, true)) + LastResort(rest); + + // Icons alone: one size for the ring, the largest every one of them fits at, centred in its slice. + var alone = Enumerable.Range(0, slices.Count).Where(i => wantIcon[i]).ToList(); + if (alone.Count > 0) + { + var size = 0.0; + for (var candidate = IconAloneMax; candidate >= IconAloneMin - 1e-9; candidate -= IconStep) + if (alone.All(i => zones[i].Contains(zones[i].At((zones[i].Inner + zones[i].Outer) / 2), SliceLabel.IconReach(candidate * k)))) + { + size = candidate * k; + break; + } + // Next to icons drawn beside names, the same size as those: one icon size for the ring. + var withName = result.Where(r => r?.Kind == SliceLabelKind.IconAndText).Select(r => r!.IconSize).DefaultIfEmpty(0).Max(); + if (size > 0 && withName > 0) + size = Math.Min(size, withName); + if (size > 0) + foreach (var i in alone) + result[i] = new SliceLabel(SliceLabelKind.Icon, null, slices[i].IconKey, zones[i].At((zones[i].Inner + zones[i].Outer) / 2), size, zones[i]); + else + LastResort(alone); + } + return result.Select(r => r ?? SliceLabel.Empty).ToArray(); + } + + /// + /// The name whole (with its caption under it when both fit), in as few lines as it takes, straight or (with + /// ) along the slice; straight when both fit. Null if it doesn't fit whole. + /// + internal LabelText? FitText(SafeZone zone, string name, string? caption, double size, double captionSize, bool alongToo, + TextWay way = TextWay.Either) + { + var units = Units(name); + if (units.Count == 0) + return null; + var turns = Turns(zone, alongToo, way); + foreach (var withCaption in caption is null ? new[] { false } : new[] { true, false }) + for (var lines = 1; lines <= Math.Min(MaxLines, units.Count); lines++) + { + var text = Split(units, lines, size); + foreach (var turn in turns) + if (Place(zone, text, size, withCaption ? caption : null, captionSize, turn, whole: true) is { } block) + return block; + } + return null; + } + + /// + /// The name on one line, shortened with "…" until it fits, keeping text elements at least. + /// Null if it doesn't fit even then. + /// + internal LabelText? FitShortened(SafeZone zone, string name, double size, bool alongToo, int minKept = 0, + TextWay way = TextWay.Either) + { + var turns = Turns(zone, alongToo, way); + var elements = TextElements(name.Trim()); + for (var keep = elements.Count - 1; keep >= minKept; keep--) + { + var text = string.Concat(elements.Take(keep)).TrimEnd() + "…"; + foreach (var turn in turns) + if (Place(zone, [text], size, null, 0, turn, whole: false) is { } block) + return block; + } + return null; + } + + /// + /// The icon above the name, both upright, placed along the slice's middle line; the name shortened to one line + /// (keeping text elements at least) if and it doesn't fit + /// whole. Null if they don't fit. + /// + internal (LabelText Text, Point Icon)? FitIconAndText(SafeZone zone, string name, string? caption, double size, + double captionSize, double iconSize, double scale, bool shorten, int minKept = 1) + { + var units = Units(name); + if (units.Count == 0) + return null; + var gap = IconGap * scale; + var options = new List<(IReadOnlyList Lines, string? Caption, bool Whole)>(); + foreach (var withCaption in caption is null ? new[] { false } : new[] { true, false }) + for (var lines = 1; lines <= Math.Min(MaxLines, units.Count); lines++) + options.Add((Split(units, lines, size), withCaption ? caption : null, true)); + if (shorten) + { + var elements = TextElements(name.Trim()); + for (var keep = elements.Count - 1; keep >= Math.Max(1, minKept); keep--) + options.Add(([string.Concat(elements.Take(keep)).TrimEnd() + "…"], null, false)); + } + foreach (var (lines, cap, whole) in options) + { + var (textWidth, heights, captionHeight) = Measure(lines, size, cap, captionSize); + var textHeight = heights.Sum() + captionHeight; + var width = Math.Max(iconSize, textWidth); + var height = iconSize + gap + textHeight; + foreach (var offset in Offsets) + { + var at = zone.At((zone.Inner + zone.Outer) / 2 + offset * zone.Depth); + var icon = new Point(at.X, at.Y - height / 2 + iconSize / 2); + var textAt = new Point(at.X, at.Y + height / 2 - textHeight / 2); + if (zone.Contains(new LabelBox(at, width, height, 0)) && zone.Contains(icon, SliceLabel.IconReach(iconSize))) + return (new LabelText(lines, heights, size, cap, captionSize, captionHeight, textWidth, textHeight, textAt, 0, whole), icon); + } + } + return null; + } + + // The turns a name is tried in: straight, along the slice, or straight then along. + private static double[] Turns(SafeZone zone, bool alongToo, TextWay way) => + !alongToo || way == TextWay.Straight ? [0] : way == TextWay.Along ? [Along(zone.Mid)] : [0, Along(zone.Mid)]; + + /// + /// The turn that runs text along a slice whose middle is at : outward on the right half, inward + /// on the left half, so it is never upside down. + /// + internal static double Along(double mid) + { + var a = (mid % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI); + return a < Math.PI ? mid - Math.PI / 2 : mid + Math.PI / 2; + } + + private LabelText? Place(SafeZone zone, IReadOnlyList lines, double size, string? caption, double captionSize, + double turn, bool whole) + { + var (width, heights, captionHeight) = Measure(lines, size, caption, captionSize); + var height = heights.Sum() + captionHeight; + foreach (var offset in Offsets) + { + var at = zone.At((zone.Inner + zone.Outer) / 2 + offset * zone.Depth); + if (zone.Contains(new LabelBox(at, width, height, turn))) + return new LabelText(lines, heights, size, caption, captionSize, captionHeight, width, height, at, turn, whole); + } + return null; + } + + private (double Width, double[] Heights, double CaptionHeight) Measure(IReadOnlyList lines, double size, + string? caption, double captionSize) + { + var width = 0.0; + var heights = new double[lines.Count]; + for (var i = 0; i < lines.Count; i++) + { + var line = SizeOf(lines[i], _fonts.Bold, size); + width = Math.Max(width, line.Width); + heights[i] = line.Height; + } + var captionHeight = 0.0; + if (caption is not null) + { + var c = SizeOf(caption, _fonts.Regular, captionSize); + width = Math.Max(width, c.Width); + captionHeight = c.Height; + } + return (width, heights, captionHeight); + } + + /// A line of text as drawn: one line, never wrapped, centred. + internal static FormattedText MakeLine(string text, Typeface face, double size, IBrush? brush, FlowDirection direction, + double width) => + new(DonutMenu.KeepKoreanWords(text), CultureInfo.CurrentUICulture, direction, face, size, brush) + { + // A pixel or two to spare: a line exactly as wide as its box would wrap, and the second line be dropped. + MaxTextWidth = width + 2, + MaxLineCount = 1, + TextAlignment = TextAlignment.Center, + }; + + private Size SizeOf(string text, Typeface face, double size) + { + var key = (text, face, size); + if (_sizes.TryGetValue(key, out var known)) + return known; + if (_sizes.Count >= MaxCachedSizes) + _sizes.Clear(); + var measured = new FormattedText(DonutMenu.KeepKoreanWords(text), CultureInfo.CurrentUICulture, _fonts.Direction, face, size, null); + return _sizes[key] = new Size(measured.Width, measured.Height); + } + + // ───────────────────────── Words and lines ───────────────────────── + + /// + /// A name's pieces a line may break between: words, and each character of a Chinese or Japanese word (those break + /// anywhere). Space: a space goes before it when it doesn't start a line. + /// + internal static List<(string Text, bool Space)> Units(string name) + { + var units = new List<(string, bool)>(); + foreach (var word in name.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (!word.Any(DonutMenu.IsHanOrKana)) + { + units.Add((word, units.Count > 0)); + continue; + } + var first = true; + foreach (var element in TextElements(word)) + { + units.Add((element, first && units.Count > 0)); + first = false; + } + } + return units; + } + + /// The pieces in lines, split so the widest line is as narrow as can be. + internal List Split(List<(string Text, bool Space)> units, int count, double size) + { + string Join(int from, int to) + { + var text = new System.Text.StringBuilder(); + for (var i = from; i < to; i++) + text.Append(i > from && units[i].Space ? " " : "").Append(units[i].Text); + return text.ToString(); + } + double Width(string line) => SizeOf(line, _fonts.Bold, size).Width; + + var n = units.Count; + if (count <= 1 || n <= 1) + return [Join(0, n)]; + List? best = null; + var narrowest = double.MaxValue; + if (count == 2) + for (var a = 1; a < n; a++) + Consider([Join(0, a), Join(a, n)]); + else + for (var a = 1; a < n - 1; a++) + for (var b = a + 1; b < n; b++) + Consider([Join(0, a), Join(a, b), Join(b, n)]); + return best!; + + void Consider(List lines) + { + var widest = lines.Max(Width); + if (widest < narrowest) + (best, narrowest) = (lines, widest); + } + } + + private static List TextElements(string text) + { + var elements = new List(); + var e = StringInfo.GetTextElementEnumerator(text); + while (e.MoveNext()) + elements.Add((string)e.Current); + return elements; + } +} diff --git a/src/Filee.App/ViewModels/GroupEditorViewModel.cs b/src/Filee.App/ViewModels/GroupEditorViewModel.cs index eddca88cf..261c4cd23 100644 --- a/src/Filee.App/ViewModels/GroupEditorViewModel.cs +++ b/src/Filee.App/ViewModels/GroupEditorViewModel.cs @@ -31,6 +31,7 @@ public GroupEditorViewModel(Preset group, UserDataStore store, ILocalizer loc, b _folderInput = folderInput; IsNew = isNew; _name = loc.DisplayName(group); + Labels = new SliceLabelFields(group, loc); foreach (var id in group.SubPresetIds) { if (store.FindPreset(id) is { IsGroup: false } preset) @@ -48,6 +49,11 @@ public GroupEditorViewModel(Preset group, UserDataStore store, ILocalizer loc, b [NotifyPropertyChangedFor(nameof(IsValid), nameof(NameError))] private string _name; + partial void OnNameChanged(string value) => Labels.NameChanged(value); + + /// The group slice's icon, short name and what it shows. + public SliceLabelFields Labels { get; } + /// The presets the group fans out, in order. public ObservableCollection Members { get; } = []; @@ -125,6 +131,7 @@ public bool Apply() _group.Kind = PresetKind.Group; _group.TargetFormat = Preset.GroupTarget; _group.SubPresetIds = Members.Select(m => m.PresetId).ToList(); + Labels.Apply(_group); return true; } diff --git a/src/Filee.App/ViewModels/IconPickerViewModel.cs b/src/Filee.App/ViewModels/IconPickerViewModel.cs new file mode 100644 index 000000000..57e3f5195 --- /dev/null +++ b/src/Filee.App/ViewModels/IconPickerViewModel.cs @@ -0,0 +1,111 @@ +// The icon picker of the preset and group editors: the slice icons by group, a search over their names and search +// words, and Default (the preset's own default icon) and None. Icon names are English (from their keys); the groups +// are translated. + +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Controls; +using Filee.Core.Localization; +using Filee.Core.Presets; + +namespace Filee.App.ViewModels; + +/// An icon in the picker. +public sealed class IconChoice(string key, bool isCurrent) +{ + public string Key { get; } = key; + + /// Its key in words, for the tooltip and screen readers: "image-edit-outline" → "Image edit outline". + public string Name { get; } = IconPickerViewModel.NameOf(key); + + public Geometry? Data => SliceIcons.Geometry(Key); + + /// The preset has this icon now. + public bool IsCurrent { get; } = isCurrent; +} + +/// A group of icons in the picker, with its translated name. +public sealed record IconSection(string Name, IReadOnlyList Icons); + +public sealed partial class IconPickerViewModel : ObservableObject +{ + private readonly ILocalizer _loc; + private readonly string? _current; + private readonly Action _picked; + + /// The preset's : null (its default), or a key. + /// Called with the choice: null for Default, for None, else a key. + public IconPickerViewModel(ILocalizer loc, string? current, Action picked) + { + _loc = loc; + _current = SliceIcons.Exists(current) || current == Preset.NoIcon ? current : null; + _picked = picked; + Rebuild(); + } + + /// Filters the icons: every word of it starts a word of an icon's name or search words. + [ObservableProperty] private string _search = ""; + + /// The groups that have an icon matching , in order. + [ObservableProperty] private IReadOnlyList _sections = []; + + /// No icon matches the search. + public bool IsEmpty => Sections.Count == 0; + + /// The preset shows its default icon (Icon unset). + public bool IsDefault => _current is null; + + /// The preset shows no icon. + public bool IsNone => _current == Preset.NoIcon; + + partial void OnSearchChanged(string value) => Rebuild(); + + partial void OnSectionsChanged(IReadOnlyList value) + { + OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(First)); + } + + private void Rebuild() + { + var found = SliceIcons.Search(Search).ToHashSet(StringComparer.Ordinal); + Sections = SliceIcons.Categories + .Select(c => new IconSection(_loc[CategoryKey(c.Category)], + c.Keys.Where(found.Contains).Select(key => new IconChoice(key, key == _current)).ToList())) + .Where(s => s.Icons.Count > 0) + .ToList(); + } + + /// The first icon the search shows, picked by Enter in the search box. + public IconChoice? First => Sections.FirstOrDefault()?.Icons[0]; + + [RelayCommand] + private void Pick(IconChoice icon) => _picked(icon.Key); + + [RelayCommand] + private void PickDefault() => _picked(null); + + [RelayCommand] + private void PickNone() => _picked(Preset.NoIcon); + + /// The name of a group ("icons.cat.files_and_folders"). + internal static string CategoryKey(SliceIconCategory category) => category switch + { + SliceIconCategory.FilesAndFolders => "icons.cat.files_and_folders", + SliceIconCategory.Pictures => "icons.cat.pictures", + SliceIconCategory.CropAndTransform => "icons.cat.crop_and_transform", + SliceIconCategory.PdfAndPages => "icons.cat.pdf_and_pages", + SliceIconCategory.VideoAndAudio => "icons.cat.video_and_audio", + SliceIconCategory.TextAndData => "icons.cat.text_and_data", + SliceIconCategory.ShareAndMove => "icons.cat.share_and_move", + _ => "icons.cat.general", + }; + + /// An icon's key in words: "image-edit-outline" → "Image edit outline". + internal static string NameOf(string key) + { + var words = key.Replace('-', ' '); + return words.Length == 0 ? words : char.ToUpperInvariant(words[0]) + words[1..]; + } +} diff --git a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs index b25ca66b8..0775fa736 100644 --- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs @@ -125,6 +125,8 @@ public ToolbarPageViewModel(UserDataStore store, ILocalizer loc, WindowService w HoleRatio = store.Settings.Donut.HoleRatio * 100; SliceOpacity = Math.Clamp(store.Settings.Donut.Opacity, DonutSettings.MinOpacity, 1) * 100; ShowCaptions = store.Settings.Donut.ShowCaptions; + SliceLabelChoices = [.. LabelShows.Select(v => new Choice(v, loc[LabelShowKey(v)]))]; + SliceLabels = SliceLabelChoices[0]; _loading = false; SelectedProfile = Profiles.FirstOrDefault(); @@ -301,6 +303,25 @@ public bool EditingFront /// The output format under the slices' names (Appearance → Show output format). [ObservableProperty] private bool _showCaptions; + /// How large the selected donut's slice names and icons are, in percent (). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LabelScaleText))] + private double _labelScale = 100; + + /// The Label scale as the slider's value shows it: "125 %". + public string LabelScaleText => (Math.Round(LabelScale) / 100).ToString("P0", System.Globalization.CultureInfo.CurrentCulture); + + /// What the selected donut's slices show when their preset doesn't say (). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DefaultShow))] + private Choice _sliceLabels = null!; + + /// Auto, Icon & text, Text only, Icon only. + public IReadOnlyList> SliceLabelChoices { get; private set; } = []; + + /// The preview donut's . + public LabelShow DefaultShow => SliceLabels?.Value ?? LabelShow.Auto; + private ToolbarProfile? Profile => SelectedProfile?.Profile; /// The preset ids of the face being edited (null without a profile). @@ -441,6 +462,10 @@ private void RebuildProfileEditor() CanChangeMixed = profile is not null && !(profile.IsFallback && mixedCount <= 1); CanDelete = CanChangeMixed; CanReset = profile is not null && _store.CanResetProfile(profile); + _loading = true; + LabelScale = ToolbarProfile.ClampLabelScale(profile?.LabelScale ?? 1) * 100; + SliceLabels = SliceLabelChoices.First(c => c.Value == (profile?.LabelShow ?? LabelShow.Auto)); + _loading = false; ExtensionTags = profile is { IsFallback: false } ? new ExtensionTagsViewModel(profile, _store.Profiles, _loc, OnExtensionsSaved) : null; MixedExtensions = profile is { IsFallback: true } ? new MixedExtensionsViewModel(profile, _store.Profiles, _loc, OnExtensionsSaved) : null; } @@ -457,6 +482,41 @@ private void OnExtensionsSaved() partial void OnSliceOpacityChanged(double value) => SaveAppearance(); partial void OnShowCaptionsChanged(bool value) => SaveAppearance(); + /// The four Show modes, in the order the combo boxes list them. + internal static readonly LabelShow[] LabelShows = [LabelShow.Auto, LabelShow.IconAndText, LabelShow.TextOnly, LabelShow.IconOnly]; + + /// The name of Show mode ("labels.icon_text"). + internal static string LabelShowKey(LabelShow show) => show switch + { + LabelShow.IconAndText => "labels.icon_text", + LabelShow.TextOnly => "labels.text_only", + LabelShow.IconOnly => "labels.icon_only", + _ => "labels.auto", + }; + + partial void OnLabelScaleChanged(double value) + { + if (_loading || Profile is not { } profile) + return; + var scale = ToolbarProfile.ClampLabelScale(value / 100); + if (scale == profile.LabelScale) + return; + profile.LabelScale = scale; + SaveOwnLibrary(); + } + + partial void OnSliceLabelsChanged(Choice? oldValue, Choice newValue) + { + if (Choices.Restore(oldValue, newValue, v => SliceLabels = v) || _loading || Profile is not { } profile) + return; + // Auto is the default: left unset, as on a donut that never chose. + var show = newValue.Value == LabelShow.Auto ? (LabelShow?)null : newValue.Value; + if (show == profile.LabelShow) + return; + profile.LabelShow = show; + SaveOwnLibrary(); + } + private void SaveAppearance() { if (_loading) @@ -723,7 +783,17 @@ private void Rebuild() .OfType() // A group is marked as one (no children: the editor doesn't fan them out), so the preview draws it as the // donut does. - .Select(p => new DonutItem { Id = p.Id, Label = _loc.DonutName(p), Caption = Caption(p), KeepsCaption = p.IsCopyToFolder, Children = p.IsGroup ? [] : null }) + .Select(p => new DonutItem + { + Id = p.Id, + Label = _loc.DonutName(p), + Caption = Caption(p), + KeepsCaption = p.IsCopyToFolder, + Children = p.IsGroup ? [] : null, + FullName = _loc.DisplayName(p), + IconKey = SliceIcons.For(p), + Show = p.Show, + }) .ToList(); Palette.Clear(); diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index dad011a79..bcfa19aa1 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -115,6 +115,7 @@ internal PresetEditorViewModel(Preset preset, ILocalizer loc, IReadOnlySet Labels.NameChanged(value); + + /// The slice's icon, short name and what it shows. + public SliceLabelFields Labels { get; } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowImage), nameof(ShowTurnForPdf), nameof(ShowQuality), nameof(ShowTiff), nameof(ShowMultiPageTiff), nameof(ShowWebp), nameof(ShowIco), nameof(ShowFitsTiff), nameof(ShowFitsDebayer), @@ -1190,6 +1196,7 @@ public bool Apply() // Keep a built-in preset's translatable name unless the user actually renamed it. if (_original.NameKey is null || Name.Trim() != _loc.DisplayName(_original)) _original.Name = Name.Trim(); + Labels.Apply(_original); _original.TargetFormat = TargetId; var image = _original.Image; diff --git a/src/Filee.App/ViewModels/RadialViewModel.cs b/src/Filee.App/ViewModels/RadialViewModel.cs index cb5043d53..5e217a4b6 100644 --- a/src/Filee.App/ViewModels/RadialViewModel.cs +++ b/src/Filee.App/ViewModels/RadialViewModel.cs @@ -28,6 +28,12 @@ public sealed partial class RadialViewModel(UserDataStore store, ILocalizer loc, /// The output format under the slices' names (Settings → Donut toolbar → Appearance). [ObservableProperty] private bool _showCaptions = true; + /// How large the slices' names and icons are (the donut's ). + [ObservableProperty] private double _labelScale = 1; + + /// What a slice shows when its preset doesn't say (the donut's ). + [ObservableProperty] private LabelShow _defaultShow = LabelShow.Auto; + /// The other face has a slice for these files: Tab flips to it. [ObservableProperty] private bool _canFlip; @@ -175,6 +181,8 @@ public void Load(IReadOnlyList files, bool clickMode, bool backFace = fa var off = store.Settings.TurnedOffFields .Except(formats.SelectMany(FormatFields.Of).Select(field => field.Id), StringComparer.OrdinalIgnoreCase) .ToHashSet(StringComparer.OrdinalIgnoreCase); + LabelScale = ToolbarProfile.ClampLabelScale(profile?.LabelScale ?? 1); + DefaultShow = profile?.LabelShow ?? LabelShow.Auto; // Both faces follow the same rule: a slice that can't convert these files is greyed out and says why. _front = BuildFace(profile?.PresetIds ?? [], formats, off); (_back, _backIds, _formats, _off) = (null, [.. profile?.BackPresetIds ?? []], formats, off); @@ -284,6 +292,9 @@ DonutItem ItemOf(Preset p, bool caption = true) KeepsCaption = p.IsCopyToFolder, IsEnabled = reason is null, DisabledReason = reason, + FullName = loc.DisplayName(p), + IconKey = SliceIcons.For(p), + Show = p.Show, }; } @@ -296,13 +307,16 @@ DonutItem ItemOf(Preset p, bool caption = true) return new DonutItem { Id = p.Id, - Label = loc.DisplayName(p), + Label = loc.DonutName(p), Caption = loc.Format("donut.group_caption", children.Count), IsEnabled = children.Any(c => c.IsEnabled), // Its presets were all deleted since it was made: say so rather than show a dead slice. DisabledReason = children.Count == 0 ? loc["donut.group_empty"] : children.Select(c => c.DisabledReason).FirstOrDefault(r => r is not null), Children = children, + FullName = loc.DisplayName(p), + IconKey = SliceIcons.For(p), + Show = p.Show, }; }).ToList(); return new Face(presets, subPresets, items); @@ -317,17 +331,20 @@ public void ShowHint(int index, int sub, bool centerHot) (sub >= 0 || Items[index].IsEnabled)) CenterSubtitle = sub >= 0 && sub < children.Count ? children[sub].IsEnabled - ? loc.Format(IsClickMode ? "donut.click_to" : "donut.drop_to", $"{Items[index].Label} · {children[sub].Label}") + ? loc.Format(IsClickMode ? "donut.click_to" : "donut.drop_to", $"{NameOf(Items[index])} · {NameOf(children[sub])}") : children[sub].DisabledReason : loc["donut.pick_in_group"]; else if (index >= 0 && index < Items.Count) CenterSubtitle = Items[index].IsEnabled - ? loc.Format(IsClickMode ? "donut.click_to" : "donut.drop_to", Items[index].Label) + ? loc.Format(IsClickMode ? "donut.click_to" : "donut.drop_to", NameOf(Items[index])) : Items[index].DisabledReason; else CenterSubtitle = IsClickMode ? loc["donut.close_hint"] : null; } + // The slice may show an icon, a short name or a shortened one: the hint names it in full. + private static string NameOf(DonutItem item) => item.FullName ?? item.Label; + /// /// Click mode, before the window is placed: room for a group's ring only if this donut has a group, on either face /// (the window must not grow, and the donut move, when it flips). diff --git a/src/Filee.App/ViewModels/SliceLabelFields.cs b/src/Filee.App/ViewModels/SliceLabelFields.cs new file mode 100644 index 000000000..e46fecf3c --- /dev/null +++ b/src/Filee.App/ViewModels/SliceLabelFields.cs @@ -0,0 +1,86 @@ +// The slice-label fields of the preset and group editors: the slice's icon (picked in the icon picker), a short name +// for the donut, and what the slice shows. Shared by PresetEditorViewModel and GroupEditorViewModel. + +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.App.Controls; +using Filee.App.ViewModels.Pages; +using Filee.Core.Localization; +using Filee.Core.Presets; + +namespace Filee.App.ViewModels; + +public sealed partial class SliceLabelFields : ObservableObject +{ + private readonly ILocalizer _loc; + private readonly Preset _preset; + private string _name; + + /// The preset or group being edited, for its current values and its default icon. + public SliceLabelFields(Preset preset, ILocalizer loc) + { + _loc = loc; + _preset = preset; + _name = loc.DisplayName(preset); + _icon = SliceIcons.Exists(preset.Icon) || preset.Icon == Preset.NoIcon ? preset.Icon : null; + _shortName = preset.ShortName ?? ""; + ShowChoices = [new Choice(null, loc["presets.show_donut"]), + .. ToolbarPageViewModel.LabelShows.Select(v => new Choice(v, loc[ToolbarPageViewModel.LabelShowKey(v)]))]; + _show = ShowChoices.FirstOrDefault(c => c.Value == preset.Show) ?? ShowChoices[0]; + } + + /// The slice's own icon: null for its default, for none, else a key. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IconData), nameof(IconText), nameof(IconPicker))] + private string? _icon; + + /// The icon the slice shows with this choice (its default for Default), or null for none. + public Geometry? IconData => SliceIcons.Geometry(Icon == Preset.NoIcon ? null : SliceIcons.Exists(Icon) ? Icon : SliceIcons.DefaultFor(_preset)); + + /// What the Icon button says: "Default", "None", or the icon's name. + public string IconText => Icon is null ? _loc["presets.icon_default"] + : Icon == Preset.NoIcon ? _loc["presets.icon_none"] + : IconPickerViewModel.NameOf(Icon); + + /// The picker in the Icon button's flyout; a pick sets . + public IconPickerViewModel IconPicker => new(_loc, Icon, key => Icon = key); + + /// The name on the donut instead of the compact name; blank for that. + [ObservableProperty] private string _shortName; + + /// The compact name the donut shows without a short name, as the Short name box's placeholder. + public string ShortNamePlaceholder + { + get + { + var probe = _preset.Clone(); + probe.ShortName = null; + if (_name.Trim() != _loc.DisplayName(_preset)) + probe.Name = _name.Trim(); + return _loc.DonutName(probe); + } + } + + /// Use the donut's setting, Auto, Icon & text, Text only, Icon only. + public IReadOnlyList> ShowChoices { get; } + + [ObservableProperty] private Choice _show; + + partial void OnShowChanged(Choice? oldValue, Choice newValue) => + Choices.Restore(oldValue, newValue, v => Show = v); + + /// The editor's name box changed: the placeholder follows it. + public void NameChanged(string name) + { + _name = name; + OnPropertyChanged(nameof(ShortNamePlaceholder)); + } + + /// Writes the fields into . + public void Apply(Preset preset) + { + preset.Icon = Icon; + preset.ShortName = string.IsNullOrWhiteSpace(ShortName) ? null : ShortName.Trim(); + preset.Show = Show.Value; + } +} diff --git a/src/Filee.App/Views/GroupEditorWindow.axaml b/src/Filee.App/Views/GroupEditorWindow.axaml index 2c22e10ff..acc2b3f46 100644 --- a/src/Filee.App/Views/GroupEditorWindow.axaml +++ b/src/Filee.App/Views/GroupEditorWindow.axaml @@ -3,6 +3,7 @@ + diff --git a/src/Filee.App/Views/IconPickerFlyout.axaml b/src/Filee.App/Views/IconPickerFlyout.axaml new file mode 100644 index 000000000..0f8c5ec2a --- /dev/null +++ b/src/Filee.App/Views/IconPickerFlyout.axaml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/IconPickerFlyout.axaml.cs b/src/Filee.App/Views/IconPickerFlyout.axaml.cs new file mode 100644 index 000000000..52e6dc02a --- /dev/null +++ b/src/Filee.App/Views/IconPickerFlyout.axaml.cs @@ -0,0 +1,109 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Filee.App.ViewModels; + +namespace Filee.App.Views; + +/// +/// The icon picker shown in a flyout: the search box takes the focus, ↓ goes to the icons, the arrows move between +/// them as they are laid out, Enter in the search picks the first match, and a pick (or Esc) closes the flyout. +/// +public partial class IconPickerFlyout : UserControl +{ + public IconPickerFlyout() + { + InitializeComponent(); + AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + // After the button's command ran: the pick is made, the flyout can go. + AddHandler(Button.ClickEvent, (_, _) => Dispatcher.UIThread.Post(Close), RoutingStrategies.Bubble, handledEventsToo: true); + Loaded += (_, _) => Dispatcher.UIThread.Post(() => SearchBox.Focus(), DispatcherPriority.Input); + } + + /// The icon buttons in reading order (group by group, row by row). + internal IReadOnlyList + + + + + + + + + + + + diff --git a/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs b/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs new file mode 100644 index 000000000..e466cf3ec --- /dev/null +++ b/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace Filee.App.Views; + +/// The slice-label fields of the preset and group editors (). +public partial class SliceLabelFieldsView : UserControl +{ + public SliceLabelFieldsView() => InitializeComponent(); +} diff --git a/src/Filee.Core/Localization/ILocalizer.cs b/src/Filee.Core/Localization/ILocalizer.cs index 67e604658..f810c23b2 100644 --- a/src/Filee.Core/Localization/ILocalizer.cs +++ b/src/Filee.Core/Localization/ILocalizer.cs @@ -46,9 +46,14 @@ public static string DisplayName(this ILocalizer loc, Preset preset) => : preset.NameKey is not null ? loc[preset.NameKey] : preset.TargetFormat.ToUpperInvariant(); - /// A compact name on the donut, where the target format is already shown underneath. + /// + /// A compact name on the donut, where the target format is already shown underneath: the preset's + /// when it has one. + /// public static string DonutName(this ILocalizer loc, Preset preset) { + if (!string.IsNullOrWhiteSpace(preset.ShortName)) + return preset.ShortName.Trim(); if (!string.IsNullOrWhiteSpace(preset.Name) || preset.NameKey is null) return loc.DisplayName(preset); var key = preset.NameKey + ".donut"; @@ -59,10 +64,12 @@ public static string DonutName(this ILocalizer loc, Preset preset) /// /// The name on a slice of a group's ring, where the group's name already says what it is about: a built-in /// preset's short name (its + .short, "Left" for "Rotate left") when it has one, - /// otherwise . + /// otherwise . The preset's own comes first. /// public static string RingName(this ILocalizer loc, Preset preset) { + if (!string.IsNullOrWhiteSpace(preset.ShortName)) + return preset.ShortName.Trim(); if (!string.IsNullOrWhiteSpace(preset.Name) || preset.NameKey is null) return loc.DisplayName(preset); var key = preset.NameKey + ".short"; diff --git a/src/Filee.Core/Presets/LabelShow.cs b/src/Filee.Core/Presets/LabelShow.cs new file mode 100644 index 000000000..7c2d857c9 --- /dev/null +++ b/src/Filee.Core/Presets/LabelShow.cs @@ -0,0 +1,60 @@ +// What a donut slice shows: its name, its icon, both, or whichever fits (Auto). + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Filee.Core.Presets; + +/// +/// What a donut slice shows (, and a donut's default in +/// ). +/// +public enum LabelShow +{ + /// + /// Whichever fits, one style for every Auto slice of a ring: icon and name if every name fits whole, else the name + /// if every name fits whole, else the icon. + /// + Auto, + + /// The icon above the name, the name shortened if it doesn't fit. + IconAndText, + + /// The name, shortened if it doesn't fit. + TextOnly, + + /// The icon, centred; a slice without an icon shows its name. + IconOnly, +} + +/// +/// Reads a by name. A value this version doesn't know (a newer Filee's) reads as unset, so the +/// slice follows its donut's setting instead of the preset file failing to read. +/// +public sealed class LabelShowJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override LabelShow? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + reader.Skip(); + return null; + } + if (reader.TokenType != JsonTokenType.String) + return null; + var text = reader.GetString(); + return Enum.GetNames().FirstOrDefault(n => string.Equals(n, text, StringComparison.OrdinalIgnoreCase)) is { } name + ? Enum.Parse(name) + : null; + } + + public override void Write(Utf8JsonWriter writer, LabelShow? value, JsonSerializerOptions options) + { + if (value is { } show) + writer.WriteStringValue(show.ToString()); + else + writer.WriteNullValue(); + } +} diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 76d16fdf0..3ae7f5acf 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -146,6 +146,22 @@ public sealed class Preset /// Where and how output files are written. public OutputRule Output { get; set; } = new(); + /// + /// The icon its donut slice shows: the key of one of the app's slice icons, for none, or null + /// for the default (a built-in preset's own icon; none for plain format conversions and the user's presets). + /// + public string? Icon { get; set; } + + /// meaning "no icon", even where there would be a default. + public const string NoIcon = "none"; + + /// A shorter name shown on its donut slice instead of the usual one. Null or blank: the usual name. + public string? ShortName { get; set; } + + /// What its donut slice shows. Null: whatever its donut says (). + [JsonConverter(typeof(LabelShowJsonConverter))] + public LabelShow? Show { get; set; } + /// A conversion or a group. A kind this version doesn't know (from a newer Filee) is read as a group. [JsonConverter(typeof(PresetKindJsonConverter))] public PresetKind Kind { get; set; } = PresetKind.Convert; @@ -404,6 +420,9 @@ public Preset ForSharing() Upscale = Upscale.Clone(), Metadata = Metadata?.Clone(), Output = Output.Clone(), + Icon = Icon, + ShortName = ShortName, + Show = Show, Kind = Kind, Tool = Tool, SubPresetIds = [.. SubPresetIds], diff --git a/src/Filee.Core/Profiles/ToolbarProfile.cs b/src/Filee.Core/Profiles/ToolbarProfile.cs index f11f9e0a9..4f31d3e5f 100644 --- a/src/Filee.Core/Profiles/ToolbarProfile.cs +++ b/src/Filee.Core/Profiles/ToolbarProfile.cs @@ -1,6 +1,8 @@ // A toolbar profile decides which presets the donut shows for a given set of source files. +using System.Text.Json.Serialization; using Filee.Core.Formats; +using Filee.Core.Presets; namespace Filee.Core.Profiles; @@ -44,6 +46,25 @@ public sealed class ToolbarProfile /// public List? BackPresetIds { get; set; } + /// What its slices show where their preset doesn't say (). Null: Auto. + [JsonConverter(typeof(LabelShowJsonConverter))] + public LabelShow? LabelShow { get; set; } + + /// + /// How large its slices' names and icons are, from to in + /// steps of : 1 is 13 px names on a donut of the default size. They also grow and shrink + /// with the donut. + /// + public double LabelScale { get; set; } = 1; + + public const double MinLabelScale = 0.8, MaxLabelScale = 1.6, LabelScaleStep = 0.05; + + /// within the allowed range, on a step; 1 for a value that isn't a number. + public static double ClampLabelScale(double scale) => + double.IsFinite(scale) + ? Math.Round(Math.Round(Math.Clamp(scale, MinLabelScale, MaxLabelScale) / LabelScaleStep) * LabelScaleStep, 2) + : 1; + /// Returns true if the extension (with or without dot) belongs to this profile. public bool Matches(string extension) => Extensions.Contains(extension.TrimStart('.'), StringComparer.OrdinalIgnoreCase); @@ -57,6 +78,8 @@ public bool Matches(string extension) => IsFallback = IsFallback, PresetIds = [.. PresetIds], BackPresetIds = BackPresetIds is null ? null : [.. BackPresetIds], + LabelShow = LabelShow, + LabelScale = LabelScale, }; } diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index d8d7825a8..8789ff4bb 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -298,6 +298,8 @@ private void EnsureConsistency() profile.PresetIds = profile.PresetIds.Where(ids.Contains).Distinct().Take(ToolbarProfile.MaxSlices).ToList(); // No back face known by now (a donut the user made, read from an older file): it has none. profile.BackPresetIds = (profile.BackPresetIds ?? []).Where(ids.Contains).Distinct().Take(ToolbarProfile.MaxSlices).ToList(); + // A hand-edited or out-of-range label size: back within the slider's range, on one of its steps. + profile.LabelScale = ToolbarProfile.ClampLabelScale(profile.LabelScale); } // A Filee from before groups drops "kind" (and the presets) when it saves a group, which comes back as a // conversion to "group": still a group, now an empty one, rather than a conversion that can never run. diff --git a/tests/Filee.App.Tests/DonutLabelPlacementTests.cs b/tests/Filee.App.Tests/DonutLabelPlacementTests.cs index 30379f6ea..6d4df79f6 100644 --- a/tests/Filee.App.Tests/DonutLabelPlacementTests.cs +++ b/tests/Filee.App.Tests/DonutLabelPlacementTests.cs @@ -88,25 +88,19 @@ private static (double X, double Y) DarkCentroid(byte[] bgra, int width, int hei return (sx / n, sy / n); } - /// - /// A word wider than its slice ("Grayscale" on a thin one) is drawn smaller rather than broken in the middle, and a - /// Korean word stays on one line. Chinese, which breaks between any two characters, keeps its size. - /// - [AvaloniaFact] - public void A_word_wider_than_its_slice_gets_smaller_instead_of_breaking() + /// A Korean word stays on one line: a word joiner goes between its syllables. + [Fact] + public void A_korean_word_is_kept_together() { - TestServices.EnsureInitialized("en"); - var donut = new DonutMenu(); - var typeface = new Typeface(FontFamily.Default, FontStyle.Normal, FontWeight.SemiBold); - - Assert.Equal(13, donut.LabelSize("Half size", typeface, 60)); - Assert.InRange(donut.LabelSize("Grayscale", typeface, 40), 10, 12.9); - Assert.Equal(10, donut.LabelSize("Supercalifragilistic", typeface, 30)); // no smaller than 10 - Assert.Equal(13, donut.LabelSize("去除背景去除背景", typeface, 40)); Assert.Equal("ZIP 하\u2060나\u2060로", DonutMenu.KeepKoreanWords("ZIP 하나로")); Assert.Equal("Merge PDF", DonutMenu.KeepKoreanWords("Merge PDF")); } + /// + /// The Portuguese names of image actions were shortened to fit a full donut's slices; they still fit whole, without + /// a word split, on every slice of a full donut of the default size at the smallest Label scale (80 %). At larger + /// scales the ring falls back to icons (Auto) or shortens the name (Text only). + /// [AvaloniaTheory] [InlineData("pt-PT", "preset.half_size")] [InlineData("pt-BR", "preset.half_size")] @@ -116,7 +110,7 @@ public void A_word_wider_than_its_slice_gets_smaller_instead_of_breaking() [InlineData("pt-BR", "preset.trace_svg")] [InlineData("pt-PT", "preset.extract_motion")] [InlineData("pt-BR", "preset.extract_motion")] - public void Portuguese_image_actions_fit_the_two_line_slice_label(string language, string key) + public void Portuguese_image_actions_fit_a_full_donuts_slices_whole(string language, string key) { TestServices.EnsureInitialized(language); var loc = AppHost.Get(); @@ -125,25 +119,14 @@ public void Portuguese_image_actions_fit_the_two_line_slice_label(string languag Assert.Equal(loc[key], loc.DisplayName(preset)); preset.Name = "My custom action"; Assert.Equal(preset.Name, loc.DonutName(preset)); - var typeface = new Typeface(SoftPalette.FontFamilyFor(language), FontStyle.Normal, FontWeight.SemiBold); - var donut = new DonutMenu(); - // Compact image donuts leave roughly 50 px for an action, over at most two lines. - using var text = new TextLayout(label, typeface, donut.LabelSize(label, typeface, 50), Brushes.Black, - textWrapping: TextWrapping.Wrap, textTrimming: TextTrimming.CharacterEllipsis, maxWidth: 50, maxLines: 2); - Assert.All(text.TextLines, line => Assert.False(line.HasCollapsed, $"{language}: {label} is truncated")); - } - - /// A caption wider than its slice wraps where the slice is deep enough, rather than ending in "Same for…". - [AvaloniaFact] - public void A_caption_wider_than_its_slice_takes_a_second_line_where_there_is_room() - { - TestServices.EnsureInitialized("en"); - var typeface = new Typeface(FontFamily.Default); - var oneLine = DonutMenu.MakeCaption("PNG", typeface, null, 45, labelHeight: 17, maxHeight: 80).Height; - - Assert.InRange(DonutMenu.MakeCaption("Same format", typeface, null, 45, labelHeight: 17, maxHeight: 80).Height, oneLine * 1.8, oneLine * 2.2); - Assert.Equal(oneLine, DonutMenu.MakeCaption("Same format", typeface, null, 45, labelHeight: 17, maxHeight: 40).Height); - Assert.Equal(oneLine, DonutMenu.MakeCaption("Same format", typeface, null, 120, labelHeight: 17, maxHeight: 80).Height); + var family = SoftPalette.FontFamilyFor(language); + var layout = new SliceLabelLayout(new LabelFonts(new Typeface(family, FontStyle.Normal, FontWeight.SemiBold), + new Typeface(family), FlowDirection.LeftToRight)); + var center = new Point(200, 200); + var whole = Enumerable.Range(0, 12).Count(i => layout.FitText( + SafeZone.Of(new SliceShape(center, OuterRadius * HoleRatio, OuterRadius, DonutGeometry.MidAngle(i, 12), DonutGeometry.Span(12), 3), 1), + label, null, SliceLabelLayout.NameSizeFor(0.8, 1), 10.5, alongToo: true) is { Whole: true }); + Assert.True(whole == 12, $"{language}: {label} fits whole on {whole} of 12 slices"); } private static (byte[] Pixels, int Width, int Height) Read(Avalonia.Media.Imaging.WriteableBitmap bitmap) diff --git a/tests/Filee.App.Tests/IconPickerTests.cs b/tests/Filee.App.Tests/IconPickerTests.cs new file mode 100644 index 000000000..4c014884c --- /dev/null +++ b/tests/Filee.App.Tests/IconPickerTests.cs @@ -0,0 +1,114 @@ +// The icon picker of the preset and group editors: its search, what Default / None / an icon return, the current +// choice marked, and the keyboard (↓ from the search, the arrows across the grid, Enter, Esc). + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.Views; +using Filee.Core.Localization; +using Filee.Core.Presets; + +namespace Filee.App.Tests; + +public class IconPickerTests +{ + private static (IconPickerViewModel Vm, List Picked) Make(string? current = null) + { + TestServices.EnsureInitialized("en"); + var picked = new List(); + return (new IconPickerViewModel(AppHost.Get(), current, picked.Add), picked); + } + + [AvaloniaFact] + public void Every_icon_is_listed_under_its_translated_group_and_the_search_filters_them() + { + var (vm, _) = Make(); + Assert.Equal(SliceIcons.Keys.Order(), vm.Sections.SelectMany(s => s.Icons).Select(i => i.Key).Distinct().Order()); + Assert.Equal("Files & folders", vm.Sections[0].Name); + Assert.Equal("Image edit outline", vm.Sections.SelectMany(s => s.Icons).Single(i => i.Key == "image-edit-outline").Name); + + vm.Search = "background"; + Assert.Contains("auto-fix", vm.Sections.SelectMany(s => s.Icons).Select(i => i.Key)); + Assert.Equal(SliceIcons.Search("background").Order(), vm.Sections.SelectMany(s => s.Icons).Select(i => i.Key).Distinct().Order()); + Assert.Equal(vm.Sections[0].Icons[0], vm.First); + + vm.Search = "zzzz"; + Assert.True(vm.IsEmpty); + Assert.Null(vm.First); + } + + [AvaloniaFact] + public void Default_none_and_an_icon_return_their_values_and_the_current_one_is_marked() + { + var (vm, picked) = Make("crop"); + Assert.False(vm.IsDefault); + Assert.True(vm.Sections.SelectMany(s => s.Icons).Single(i => i.IsCurrent).Key == "crop"); + vm.PickDefaultCommand.Execute(null); + vm.PickNoneCommand.Execute(null); + vm.PickCommand.Execute(vm.Sections[0].Icons[0]); + Assert.Equal([null, Preset.NoIcon, vm.Sections[0].Icons[0].Key], picked); + + Assert.True(Make().Vm.IsDefault); + Assert.True(Make(Preset.NoIcon).Vm.IsNone); + Assert.True(Make("an-icon-from-a-newer-filee").Vm.IsDefault); // unknown here: the default shows + } + + [AvaloniaFact] + public void The_keyboard_moves_through_the_grid_picks_and_closes() + { + var (vm, picked) = Make(); + var picker = new IconPickerFlyout { DataContext = vm }; + var button = new Button { Content = "Icon", Flyout = new Flyout { Content = picker } }; + var window = new Window { Content = button, Width = 500, Height = 700 }; + window.Show(); + button.Flyout.ShowAt(button); + Dispatcher.UIThread.RunJobs(); + Assert.True(picker.SearchBox.IsFocused); + + // ↓ from the search: the first icon. → the next. ↓ the one under it in the next row. ↑ back. + window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); + var icons = picker.IconButtons; + Assert.True(icons[0].IsFocused); + window.KeyPressQwerty(PhysicalKey.ArrowRight, RawInputModifiers.None); + Assert.True(icons[1].IsFocused); + double Top(Button b) => b.TranslatePoint(default, picker)!.Value.Y; + var perRow = icons.TakeWhile(b => Top(b) == Top(icons[0])).Count(); + Assert.InRange(perRow, 6, 10); + window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); + Assert.True(icons[1 + perRow].IsFocused); + window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None); + Assert.True(icons[1].IsFocused); + window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None); + Assert.True(picker.SearchBox.IsFocused); + + // Enter on an icon picks it and closes the flyout. + window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); + window.KeyPressQwerty(PhysicalKey.ArrowRight, RawInputModifiers.None); + window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + Assert.Equal([vm.Sections[0].Icons[1].Key], picked); + Assert.False(button.Flyout.IsOpen); + + // Enter in the search picks the first match; Esc closes without a pick. + button.Flyout.ShowAt(button); + Dispatcher.UIThread.RunJobs(); + vm.Search = "background"; + window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + Assert.Equal("auto-fix", picked[^1]); + button.Flyout.ShowAt(button); + Dispatcher.UIThread.RunJobs(); + window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + Assert.False(button.Flyout.IsOpen); + Assert.Equal(2, picked.Count); + window.Close(); + } +} diff --git a/tests/Filee.App.Tests/RadialLabelTests.cs b/tests/Filee.App.Tests/RadialLabelTests.cs new file mode 100644 index 000000000..301855b31 --- /dev/null +++ b/tests/Filee.App.Tests/RadialLabelTests.cs @@ -0,0 +1,112 @@ +// What the live donut's slices are given for their labels: the preset's full name (for the centre hint), its icon (its +// own, its default, none), what it shows (the preset's setting, else the donut's), and the donut's Label scale. + +using Avalonia.Headless.XUnit; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class RadialLabelTests +{ + private static (UserDataStore Store, RadialViewModel Vm) Start() + { + TestServices.EnsureInitialized("en"); + return (AppHost.Get(), AppHost.Get()); + } + + private static DonutItem ItemFor(RadialViewModel vm, string id) => + vm.Items.Concat(vm.Items.SelectMany(i => i.Children ?? [])).First(i => i.Id == id); + + [AvaloniaFact] + public void A_slice_gets_its_presets_own_icon_its_default_or_none() + { + var (store, vm) = Start(); + var crop = store.FindPreset("tool-crop")!; + var before = crop.Icon; + try + { + vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); + Assert.Equal("crop", ItemFor(vm, "tool-crop").IconKey); // the built-in default + Assert.Null(ItemFor(vm, "to-avif").IconKey); // a format conversion + + crop.Icon = "star-outline"; + vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); + Assert.Equal("star-outline", ItemFor(vm, "tool-crop").IconKey); + + crop.Icon = Preset.NoIcon; + vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); + Assert.Null(ItemFor(vm, "tool-crop").IconKey); + } + finally + { + crop.Icon = before; + } + } + + [AvaloniaFact] + public void A_slice_shows_what_its_preset_says_else_what_the_donut_says() + { + var (store, vm) = Start(); + var images = store.Profiles.Single(p => p.Id == "images"); + var crop = store.FindPreset("tool-crop")!; + var (show, scale, cropShow) = (images.LabelShow, images.LabelScale, crop.Show); + try + { + vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); + Assert.Null(ItemFor(vm, "tool-crop").Show); + Assert.Equal(LabelShow.Auto, vm.DefaultShow); // unset: Auto + Assert.Equal(1, vm.LabelScale); + + images.LabelShow = LabelShow.TextOnly; + images.LabelScale = 1.25; + crop.Show = LabelShow.IconOnly; + vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); + Assert.Equal(LabelShow.IconOnly, ItemFor(vm, "tool-crop").Show); + Assert.Null(ItemFor(vm, "rotate").Show); + Assert.Equal(LabelShow.TextOnly, vm.DefaultShow); + Assert.Equal(1.25, vm.LabelScale); + + // The window passes both to the donut. + var window = new Views.RadialWindow { DataContext = vm }; + window.Show(); + Assert.Equal(LabelShow.TextOnly, window.DonutControl.DefaultShow); + Assert.Equal(1.25, window.DonutControl.LabelScale); + window.Close(); + } + finally + { + (images.LabelShow, images.LabelScale, crop.Show) = (show, scale, cropShow); + } + } + + [AvaloniaFact] + public void The_hint_names_an_icon_only_ring_slice_and_its_group_in_full() + { + var (store, vm) = Start(); + var rotate = store.FindPreset("rotate")!; + var before = rotate.ShortName; + try + { + rotate.ShortName = "Rot"; + vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); + var index = vm.Items.ToList().FindIndex(i => i.Id == "rotate"); + var group = vm.Items[index]; + Assert.Equal("Rot", group.Label); + Assert.Equal("Rotate", group.FullName); + var left = group.Children![0]; + Assert.Equal("Left", left.Label); // the ring's compact name + Assert.Equal("Rotate left", left.FullName); + + vm.ShowHint(index, 0, centerHot: false); + Assert.Contains("Rotate · Rotate left", vm.CenterSubtitle); + } + finally + { + rotate.ShortName = before; + } + } +} diff --git a/tests/Filee.App.Tests/SliceIconsTests.cs b/tests/Filee.App.Tests/SliceIconsTests.cs new file mode 100644 index 000000000..61a4b913e --- /dev/null +++ b/tests/Filee.App.Tests/SliceIconsTests.cs @@ -0,0 +1,97 @@ +// The slice icons: every one draws, every built-in preset that does more than convert to a format has its own icon and +// plain format conversions have none, a preset's own icon (or none) wins over the default, and the picker's search. + +using Avalonia.Headless.XUnit; +using Filee.App.Controls; +using Filee.Core.Presets; + +namespace Filee.App.Tests; + +public class SliceIconsTests +{ + [AvaloniaFact] + public void Every_icon_draws_inside_its_square() + { + foreach (var key in SliceIcons.Keys) + { + var bounds = SliceIcons.Geometry(key)!.Bounds; + Assert.True(bounds.Width > 0 && bounds.Height > 0, key); + Assert.InRange(bounds.Left, -0.01, SliceIcons.Box); + Assert.InRange(bounds.Right, 0, SliceIcons.Box + 0.01); + Assert.InRange(bounds.Top, -0.01, SliceIcons.Box); + Assert.InRange(bounds.Bottom, 0, SliceIcons.Box + 0.01); + } + } + + [Fact] + public void Every_icon_is_in_the_picker_and_no_key_is_listed_twice() + { + var keys = SliceIcons.Keys.ToList(); + Assert.Equal(keys.Count, keys.Distinct().Count()); + Assert.Equal(keys.ToHashSet(), SliceIcons.Categories.SelectMany(c => c.Keys).ToHashSet()); + Assert.Equal(Enum.GetValues(), SliceIcons.Categories.Select(c => c.Category)); + } + + [Fact] + public void Built_in_presets_that_do_more_than_convert_have_an_icon_and_format_conversions_none() + { + foreach (var preset in BuiltInData.CreatePresets()) + { + var icon = SliceIcons.DefaultFor(preset); + if (icon is not null) + Assert.True(SliceIcons.Exists(icon), $"{preset.Id}: {icon}"); + else + Assert.Equal(PresetKind.Convert, preset.Kind); // a conversion to a format, named by it + } + // 48 here (the 53 reviewed, less the Actions branch's 6, plus AI Captions) and 89 format conversions without one. + Assert.Equal(89, BuiltInData.CreatePresets().Count(p => SliceIcons.DefaultFor(p) is null)); + // The ones the user reviewed, spot-checked. + Assert.Equal("crop", SliceIcons.DefaultFor(Find("tool-crop"))); + Assert.Equal("auto-fix", SliceIcons.DefaultFor(Find("remove-background"))); + Assert.Equal("rotate-right", SliceIcons.DefaultFor(Find("rotate"))); + Assert.Null(SliceIcons.DefaultFor(Find("to-png"))); + Assert.Null(SliceIcons.DefaultFor(Find("mp4-720"))); + } + + [Fact] + public void Every_built_in_group_and_tool_has_an_icon() + { + Assert.All(BuiltInData.CreatePresets().Where(p => p.IsGroup || p.IsTool), + p => Assert.NotNull(SliceIcons.DefaultFor(p))); + } + + [Fact] + public void A_copy_of_a_tool_shows_its_tools_icon() + { + Assert.Equal("content-cut", SliceIcons.DefaultFor(new Preset { Kind = PresetKind.Tool, Tool = Preset.ClipTool })); + Assert.Equal("crop", SliceIcons.DefaultFor(new Preset { Kind = PresetKind.Tool })); + Assert.Null(SliceIcons.DefaultFor(new Preset { Name = "My PNG" })); + } + + [Fact] + public void A_presets_own_icon_wins_and_none_means_none() + { + var crop = Find("tool-crop"); + Assert.Equal("crop", SliceIcons.For(crop)); + crop.Icon = "star-outline"; + Assert.Equal("star-outline", SliceIcons.For(crop)); + crop.Icon = Preset.NoIcon; + Assert.Null(SliceIcons.For(crop)); + crop.Icon = "an-icon-from-a-newer-filee"; + Assert.Equal("crop", SliceIcons.For(crop)); + Assert.Equal("star-outline", SliceIcons.For(new Preset { Icon = "star-outline" })); + } + + [Fact] + public void The_picker_finds_icons_by_name_and_search_words() + { + Assert.Contains("crop", SliceIcons.Search("crop")); + Assert.Contains("auto-fix", SliceIcons.Search("background")); + Assert.Contains("console", SliceIcons.Search("term")); + Assert.Contains("arrow-collapse-all", SliceIcons.Search("Compress")); + Assert.Empty(SliceIcons.Search("zzzz")); + Assert.Equal(SliceIcons.Keys, SliceIcons.Search(" ")); + } + + private static Preset Find(string id) => BuiltInData.CreatePresets().Single(p => p.Id == id); +} diff --git a/tests/Filee.App.Tests/SliceLabelEditorTests.cs b/tests/Filee.App.Tests/SliceLabelEditorTests.cs new file mode 100644 index 000000000..2ae242d80 --- /dev/null +++ b/tests/Filee.App.Tests/SliceLabelEditorTests.cs @@ -0,0 +1,128 @@ +// The slice-label fields of the preset and group editors: they show the preset's icon, short name and Show setting, +// save them, clear them, and the donut shows the result. + +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.Views; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class SliceLabelEditorTests +{ + private static ILocalizer Loc() + { + TestServices.EnsureInitialized("en"); + return AppHost.Get(); + } + + [AvaloniaFact] + public void The_preset_editor_shows_saves_and_clears_the_slice_label_fields() + { + var loc = Loc(); + var preset = BuiltInData.CreatePresets().Single(p => p.Id == "remove-background"); + var vm = new PresetEditorViewModel(preset, loc); + var labels = vm.Labels; + Assert.Null(labels.Icon); + Assert.Equal("Default", labels.IconText); + Assert.NotNull(labels.IconData); // its default icon + Assert.Equal("", labels.ShortName); + Assert.Equal(loc.DonutName(preset), labels.ShortNamePlaceholder); + Assert.Null(labels.Show.Value); + Assert.Equal(["Use the donut's setting", "Auto", "Icon & text", "Text only", "Icon only"], labels.ShowChoices.Select(c => c.Label)); + + // Picked in the picker, typed, chosen; saved. + labels.IconPicker.PickCommand.Execute(labels.IconPicker.Sections[0].Icons[0]); + labels.ShortName = " No bg "; + labels.Show = labels.ShowChoices.Single(c => c.Value == LabelShow.IconOnly); + Assert.True(vm.Apply()); + Assert.Equal(labels.IconPicker.Sections[0].Icons[0].Key, preset.Icon); + Assert.Equal("No bg", preset.ShortName); + Assert.Equal(LabelShow.IconOnly, preset.Show); + + // Reopened: the saved values; then None, a blank short name and the donut's setting clear them. + vm = new PresetEditorViewModel(preset, loc); + Assert.Equal("No bg", vm.Labels.ShortName); + Assert.Equal(LabelShow.IconOnly, vm.Labels.Show.Value); + vm.Labels.IconPicker.PickNoneCommand.Execute(null); + Assert.Equal("None", vm.Labels.IconText); + Assert.Null(vm.Labels.IconData); + vm.Labels.ShortName = " "; + vm.Labels.Show = vm.Labels.ShowChoices[0]; + Assert.True(vm.Apply()); + Assert.Equal(Preset.NoIcon, preset.Icon); + Assert.Null(preset.ShortName); + Assert.Null(preset.Show); + + // Default again. + vm = new PresetEditorViewModel(preset, loc); + vm.Labels.IconPicker.PickDefaultCommand.Execute(null); + Assert.True(vm.Apply()); + Assert.Null(preset.Icon); + } + + [AvaloniaFact] + public void The_short_name_placeholder_follows_the_name_being_typed() + { + var loc = Loc(); + var vm = new PresetEditorViewModel(new Preset { Name = "My PNG", TargetFormat = "png" }, loc); + Assert.Equal("My PNG", vm.Labels.ShortNamePlaceholder); + vm.Name = "Small PNG"; + Assert.Equal("Small PNG", vm.Labels.ShortNamePlaceholder); + } + + [AvaloniaFact] + public void The_group_editor_saves_the_slice_label_fields_and_the_donut_shows_them() + { + var loc = Loc(); + var store = AppHost.Get(); + var rotate = store.FindPreset("rotate")!; + var before = (rotate.Icon, rotate.ShortName, rotate.Show); + try + { + var vm = new GroupEditorViewModel(rotate, store, loc); + Assert.Equal("Default", vm.Labels.IconText); + vm.Labels.IconPicker.PickCommand.Execute(vm.Labels.IconPicker.Sections[0].Icons[0]); + vm.Labels.ShortName = "Turn"; + vm.Labels.Show = vm.Labels.ShowChoices.Single(c => c.Value == LabelShow.TextOnly); + Assert.True(vm.Apply()); + + var radial = AppHost.Get(); + radial.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); + var item = radial.Items.Single(i => i.Id == "rotate"); + Assert.Equal("Turn", item.Label); + Assert.Equal("Rotate", item.FullName); + Assert.Equal(rotate.Icon, item.IconKey); + Assert.Equal(LabelShow.TextOnly, item.Show); + } + finally + { + (rotate.Icon, rotate.ShortName, rotate.Show) = before; + } + } + + [AvaloniaFact] + public void Both_editors_show_the_fields() + { + var loc = Loc(); + var store = AppHost.Get(); + var preset = new PresetEditorView { DataContext = new PresetEditorViewModel(BuiltInData.CreatePresets()[0], loc) }; + var group = new GroupEditorWindow { DataContext = new GroupEditorViewModel(store.FindPreset("rotate")!.Clone(), store, loc) }; + var window = new Window { Content = preset }; + window.Show(); + group.Show(); + foreach (var root in new Control[] { window, group }) + { + var fields = root.GetVisualDescendants().OfType().Single(); + Assert.IsType(fields.DataContext); + Assert.Equal("Default", fields.GetVisualDescendants().OfType().First(t => t.Text == "Default").Text); + } + window.Close(); + group.Close(); + } +} diff --git a/tests/Filee.App.Tests/SliceLabelFitTests.cs b/tests/Filee.App.Tests/SliceLabelFitTests.cs new file mode 100644 index 000000000..5c388a2b4 --- /dev/null +++ b/tests/Filee.App.Tests/SliceLabelFitTests.cs @@ -0,0 +1,157 @@ +// Every built-in donut, front and back and every group's ring, at the smallest, default and largest donut, at Label +// scale 80 / 100 / 160 %, in the four Show modes and every language: each label stays inside its slice's safe zone, a +// whole name keeps its words whole (Chinese and Japanese may break between characters), and a ring has one name size +// and one icon size. And never a stub: with Auto or Icon & text, a ring's slices that have an icon show one style, a +// name with an icon is shortened (Icon & text only) to 60 % of its letters at most, an icon never comes with a +// caption, names alone show their captions on all or none, and the face's names are 11 px × the donut's size at least +// (never under 10 px, or as small as the Label scale asks). Layout only, nothing is drawn. + +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class SliceLabelFitTests +{ + private static readonly double[] Radii = [100, 150, 240]; + private static readonly double[] Scales = [0.8, 1, 1.6]; + + [AvaloniaFact] + public void Every_label_of_every_built_in_donut_fits_its_slice_in_every_language() + { + var problems = new List(); + try + { + foreach (var language in AppLanguages.All.Select(l => l.Code)) + { + TestServices.EnsureInitialized(language); + var loc = AppHost.Get(); + var store = AppHost.Get(); + var family = SoftPalette.FontFamilyFor(language); + var bold = new Typeface(family, FontStyle.Normal, FontWeight.SemiBold); + var regular = new Typeface(family); + var donut = new DonutMenu(); + foreach (var profile in BuiltInData.CreateProfiles()) + foreach (var (faceName, ids) in new[] { ("front", profile.PresetIds), ("back", profile.BackPresetIds ?? []) }) + { + donut.Items = Items(ids, store, loc); + foreach (var radius in Radii) + foreach (var scale in Scales) + foreach (var show in Enum.GetValues()) + { + donut.OuterRadius = radius; + donut.LabelScale = scale; + donut.DefaultShow = show; + var (face, rings) = donut.PlanLabels(bold, regular); + var where = $"{language} {profile.Id} {faceName} r{radius} ×{scale} {show}"; + Check(face, donut.Items, where, problems, show); + var floor = Math.Min(SliceLabelLayout.NameSizeFor(scale, donut.DonutScale), + Math.Max(SliceLabelLayout.MinNameSize, SliceLabelLayout.MinStepNameSize * donut.DonutScale)); + if (face.Select(l => l.Text?.Size).OfType().Any(size => size < floor - 1e-9)) + problems.Add($"{where}: names under {floor:0.#} px"); + for (var g = 0; g < rings.Count; g++) + Check(rings[g], donut.Items[g].Children ?? [], $"{where} ring {donut.Items[g].Id}", problems, show); + } + } + } + } + finally + { + TestServices.EnsureInitialized("en"); + } + Assert.True(problems.Count == 0, $"{problems.Count} problems:\n{string.Join("\n", problems.Take(40))}"); + } + + // The slices as the live donut gives them (RadialViewModel.ItemOf), without checking them against files. + private static List Items(IEnumerable ids, UserDataStore store, ILocalizer loc) + { + DonutItem ItemOf(Preset p, bool ring) => new() + { + Id = p.Id, + Label = ring ? loc.RingName(p) : loc.DonutName(p), + Caption = ring ? null : p.TargetFormat == BuiltInData.SameAsSource ? "JPG/PNG" : FormatLabels.NameOf(loc, p.TargetFormat), + FullName = loc.DisplayName(p), + IconKey = SliceIcons.For(p), + }; + return ids.Select(store.FindPreset).OfType().Select(p => + { + if (!p.IsGroup) + return ItemOf(p, ring: false); + var children = p.SubPresetIds.Select(store.FindPreset).OfType().Where(c => !c.IsGroup) + .Take(Preset.MaxSubPresets).Select(c => ItemOf(c, ring: true)).ToList(); + return new DonutItem + { + Id = p.Id, + Label = loc.DonutName(p), + Caption = loc.Format("donut.group_caption", children.Count), + FullName = loc.DisplayName(p), + IconKey = SliceIcons.For(p), + Children = children, + }; + }).ToList(); + } + + private static void Check(IReadOnlyList ring, IReadOnlyList items, string where, List problems, + LabelShow show) + { + var styles = ring.Where((_, i) => items[i].IconKey is not null && items[i].Label.Length > 0).Select(l => l.Kind).Distinct().ToList(); + if (show is LabelShow.Auto or LabelShow.IconAndText && styles.Count > 1) + problems.Add($"{where}: mixed styles {string.Join(", ", styles)}"); + // One direction for the ring's names: straight, or along their slices (at 3 and 9 o'clock the two look alike). + var ways = new HashSet { "straight", "along" }; + foreach (var l in ring.Where(l => l.Text is not null)) + { + var t = l.Text!; + var could = new HashSet(); + if (Math.Abs(Math.IEEERemainder(t.Rotation, 2 * Math.PI)) < 1e-6) + could.Add("straight"); + if (Math.Abs(Math.IEEERemainder(t.Rotation - SliceLabelLayout.Along(l.Zone.Mid), 2 * Math.PI)) < 1e-6) + could.Add("along"); + ways.IntersectWith(could); + } + if (ways.Count == 0) + problems.Add($"{where}: names run in different directions"); + var captioned = ring.Where((l, i) => l.Kind == SliceLabelKind.Text && !string.IsNullOrEmpty(items[i].Caption) && + !string.Equals(items[i].Caption, items[i].Label, StringComparison.OrdinalIgnoreCase)).ToList(); + var shown = captioned.Count(l => l.Text!.Caption is not null); + if (shown > 0 && shown < captioned.Count) + problems.Add($"{where}: captions on {shown} of {captioned.Count} names"); + if (ring.Select(l => l.Text?.Size).OfType().Distinct().Count() > 1) + problems.Add($"{where}: name sizes {string.Join(", ", ring.Select(l => l.Text?.Size).OfType().Distinct())}"); + if (ring.Where(l => l.IconSize > 0).Select(l => l.IconSize).Distinct().Count() > 1) + problems.Add($"{where}: icon sizes {string.Join(", ", ring.Where(l => l.IconSize > 0).Select(l => l.IconSize).Distinct())}"); + for (var i = 0; i < ring.Count; i++) + { + var label = ring[i]; + var name = items[i].Label; + if (label.Kind == SliceLabelKind.None && name.Length > 0) + problems.Add($"{where}: \"{name}\" shows nothing"); + if (label.Kind == SliceLabelKind.IconAndText && label.Text?.Caption is not null) + problems.Add($"{where}: \"{name}\" has an icon and a caption"); + // A format's name (no icon to fall back on) is shortened as far as it takes on the smallest donuts, as + // Text only is: the centre names it in full. + if (label.Text is { Whole: false } cut && show != LabelShow.TextOnly && items[i].IconKey is not null) + { + var kept = new System.Globalization.StringInfo(cut.Lines[0].TrimEnd('…').TrimEnd()).LengthInTextElements; + if (kept < SliceLabelLayout.MinKept(name) || (show == LabelShow.Auto && items[i].IconKey is not null)) + problems.Add($"{where}: \"{name}\" cut to \"{cut.Lines[0]}\""); + } + if (label.Text is { } text) + { + if (!label.Zone.Contains(text.Box)) + problems.Add($"{where}: \"{name}\" leaves its zone"); + var lines = string.Join(" ", text.Lines); + if (text.Whole && !name.Any(DonutMenu.IsHanOrKana) && lines != string.Join(" ", name.Split(' ', StringSplitOptions.RemoveEmptyEntries))) + problems.Add($"{where}: \"{name}\" drawn as \"{string.Join(" | ", text.Lines)}\""); + } + if (label.IconSize > 0 && !label.Zone.Contains(label.IconCenter, SliceLabel.IconReach(label.IconSize))) + problems.Add($"{where}: the icon of \"{name}\" leaves its zone"); + } + } +} diff --git a/tests/Filee.App.Tests/SliceLabelLayoutTests.cs b/tests/Filee.App.Tests/SliceLabelLayoutTests.cs new file mode 100644 index 000000000..aac50adde --- /dev/null +++ b/tests/Filee.App.Tests/SliceLabelLayoutTests.cs @@ -0,0 +1,481 @@ +// How slice labels fit their slices: the safe zone (inset on every side, clear of a group's chevron, the inner edge +// curving in), names wrapped at word breaks and never split (except Chinese and Japanese), drawn straight or along the +// slice, icons above names or alone and centred, and one size and one style per ring (Show, Auto). + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Threading; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.Core.Presets; + +namespace Filee.App.Tests; + +public class SliceLabelLayoutTests +{ + private static readonly Point Center = new(300, 300); + + // The default donut: radius 150, hole 42 %, 12 slices. + private const double Outer = 150, Inner = 63, Gap = 3; + + private static SliceShape Shape(int index, int count, double outerKeepOut = 0) => + new(Center, Inner, Outer, DonutGeometry.MidAngle(index, count), DonutGeometry.Span(count), Gap, outerKeepOut); + + // The app's own font (Noto Sans): the headless test font's lines are shorter, which would make names fit that don't. + private static SliceLabelLayout Layout() + { + TestServices.EnsureInitialized("en"); + var family = SoftPalette.FontFamilyFor("en"); + return new SliceLabelLayout(new LabelFonts(new Typeface(family, FontStyle.Normal, FontWeight.SemiBold), new Typeface(family), + FlowDirection.LeftToRight)); + } + + private static SliceLabelInput Slice(string name, int index, int count, LabelShow show = LabelShow.TextOnly, + string? icon = null, string? caption = null) => + new(name, caption, icon, show, Shape(index, count)); + + // ───────────────────────── The safe zone ───────────────────────── + + [Fact] + public void The_zone_is_the_slice_inset_on_every_side() + { + var zone = SafeZone.Of(Shape(0, 12), scale: 1); + var margin = 0.08 * (Outer - Inner); // 6.96: more than the 6 px floor + Assert.Equal(Inner + margin, zone.Inner, 6); + Assert.Equal(Outer - margin, zone.Outer, 6); + Assert.Equal(margin + Gap / 2, zone.Side, 6); + + // A thin ring: the 6 px floor, scaled with the donut. + var thin = SafeZone.Of(new SliceShape(Center, 100, 140, 0, 0.5, Gap), scale: 1.6); + Assert.Equal(100 + 6 * 1.6, thin.Inner, 6); + } + + [Fact] + public void A_group_slice_keeps_clear_of_its_chevron() + { + var plain = SafeZone.Of(Shape(0, 12), 1); + var group = SafeZone.Of(Shape(0, 12, outerKeepOut: 15.5), 1); + Assert.Equal(Outer - 15.5, group.Outer, 6); + Assert.True(group.Outer < plain.Outer); + } + + [Fact] + public void A_box_whose_corners_are_inside_but_whose_edge_dips_past_the_inner_edge_does_not_fit() + { + // A wide, flat box at 12 o'clock whose bottom edge is 1 px inside the hole at its middle: its corners, further + // out, are in the zone, but the inner edge curves into the box between them. + var zone = SafeZone.Of(new SliceShape(Center, 63, 150, 0, Math.PI, Gap), 1); + var d = zone.Inner - 1; + var box = new LabelBox(new Point(Center.X, Center.Y - d - 2), 60, 4, 0); + Assert.True(zone.Contains(new Point(Center.X - 30, Center.Y - d))); + Assert.True(zone.Contains(new Point(Center.X + 30, Center.Y - d))); + Assert.False(zone.Contains(box)); + Assert.True(zone.Contains(box with { Center = new Point(Center.X, Center.Y - d - 12) })); + + // Dipping in only a fifth of a pixel, off the box's middle: every point a sample of its edge might take (each + // sixth of it) is 6 px across from the dip and outside the hole; the edge itself isn't. + var shallow = zone.Inner - 0.2; + var off = new LabelBox(new Point(Center.X + 6, Center.Y - shallow - 2), 72, 4, 0); + Assert.True(zone.Contains(new Point(Center.X + 6, Center.Y - shallow))); + Assert.True(zone.Contains(new Point(Center.X - 6, Center.Y - shallow))); + Assert.False(zone.Contains(off)); + } + + [Fact] + public void Nothing_near_a_side_edge_fits() + { + var zone = SafeZone.Of(Shape(0, 12), 1); + var edge = DonutGeometry.PointAt(Center, 100, -Math.PI / 12 + 0.01); // just inside the slice's left edge + Assert.False(zone.Contains(edge)); + Assert.True(zone.Contains(DonutGeometry.PointAt(Center, 100, 0))); + } + + // ───────────────────────── Words, lines and direction ───────────────────────── + + [AvaloniaTheory] + [InlineData("Merge images", 1.0)] + [InlineData("Grayscale", 1.0)] + [InlineData("Remove metadata", 0.8)] // at 100 % its words are too wide for a full donut: icons (Auto) or "…" + [InlineData("Remove background", 0.8)] + public void Names_fit_whole_on_every_slice_of_a_full_donut(string name, double labelScale) + { + var layout = Layout(); + var size = SliceLabelLayout.NameSizeFor(labelScale, 1); + var fits = Enumerable.Range(0, 12).Count(i => + layout.FitText(SafeZone.Of(Shape(i, 12), 1), name, null, size, 10.5, alongToo: true) is { Whole: true }); + Assert.Equal(12, fits); + } + + [AvaloniaFact] + public void A_name_with_a_word_too_wide_for_a_full_donut_at_100_percent_doesnt_fit_whole() + { + var layout = Layout(); + Assert.All(Enumerable.Range(0, 12), i => + Assert.Null(layout.FitText(SafeZone.Of(Shape(i, 12), 1), "Remove background", null, 13, 10.5, alongToo: true))); + Assert.NotNull(layout.FitText(SafeZone.Of(Shape(0, 6), 1), "Remove background", null, 13, 10.5, alongToo: true)); + } + + [AvaloniaFact] + public void A_word_is_never_split() + { + var layout = Layout(); + var block = layout.FitText(SafeZone.Of(Shape(3, 12), 1), "Trim transparent borders", null, 13, 10.5, alongToo: true); + if (block is not null) + Assert.All(block.Lines, line => Assert.All(line.Split(' '), w => Assert.Contains(w, new[] { "Trim", "transparent", "borders" }))); + // Too long a word for any slice: no layout rather than a split one. + Assert.Null(layout.FitText(SafeZone.Of(Shape(0, 12), 1), "Supercalifragilisticexpialidocious", null, 13, 10.5, alongToo: true)); + } + + [Fact] + public void Chinese_and_Japanese_break_between_characters_and_Korean_keeps_its_words() + { + Assert.Equal(4, SliceLabelLayout.Units("去除背景").Count); + Assert.Equal(["背", "景"], SliceLabelLayout.Units("背景").Select(u => u.Text)); + Assert.Equal(["ZIP", "하나로"], SliceLabelLayout.Units("ZIP 하나로").Select(u => u.Text)); + Assert.Equal([false, true], SliceLabelLayout.Units("Merge PDF").Select(u => u.Space)); + } + + [AvaloniaFact] + public void Lines_are_split_so_the_widest_is_as_narrow_as_can_be() + { + var layout = Layout(); + var lines = layout.Split(SliceLabelLayout.Units("Trim transparent borders"), 2, 13); + Assert.Equal(["Trim transparent", "borders"], lines); + Assert.Equal(["Trim", "transparent", "borders"], layout.Split(SliceLabelLayout.Units("Trim transparent borders"), 3, 13)); + } + + [Fact] + public void Text_along_a_slice_is_never_upside_down() + { + // Upright: the text's reading direction never points left (cos ≥ 0), all round the donut. + for (var mid = 0.0; mid < 2 * Math.PI; mid += 0.05) + Assert.True(Math.Cos(SliceLabelLayout.Along(mid)) >= -1e-9, $"upside down at {mid:F2}"); + // It runs along the slice: at right angles to the straight direction everywhere but 3 and 9 o'clock. + Assert.Equal(0, Math.Sin(SliceLabelLayout.Along(Math.PI / 2)), 6); + Assert.Equal(1, Math.Abs(Math.Sin(SliceLabelLayout.Along(Math.PI))), 6); + } + + [AvaloniaFact] + public void Straight_wins_when_both_directions_fit() + { + var block = Layout().FitText(SafeZone.Of(Shape(0, 6), 1), "Crop", null, 13, 10.5, alongToo: true)!; + Assert.Equal(0, block.Rotation); + Assert.Single(block.Lines); + } + + [AvaloniaFact] + public void A_name_too_long_to_fit_is_shortened_on_one_line_where_allowed() + { + var block = Layout().FitShortened(SafeZone.Of(Shape(3, 12), 1), "Trim transparent borders", 13, alongToo: true)!; + Assert.False(block.Whole); + Assert.EndsWith("…", Assert.Single(block.Lines)); + } + + // ───────────────────────── Rings ───────────────────────── + + [AvaloniaFact] + public void Every_name_on_a_ring_is_one_size_and_inside_its_zone() + { + string[] names = ["Crop", "Rotate", "Flip", "Remove background", "Trim transparent borders", "Grayscale", "AVIF", + "Merge images", "Merge PDF", "Compress image", "Remove metadata", "Actions"]; + var plan = Layout().PlanRing(names.Select((n, i) => Slice(n, i, 12)).ToList(), labelScale: 1, scale: 1, showCaptions: true); + Assert.Single(plan.Where(l => l.Text is not null).Select(l => l.Text!.Size).Distinct()); + Assert.All(plan, l => Assert.True(l.Text is null || l.Zone.Contains(l.Text.Box))); + } + + [AvaloniaFact] + public void Auto_shows_one_style_for_the_whole_ring() + { + var layout = Layout(); + // Six roomy slices with short names and icons: icon and name on every one. + string[] six = ["Crop", "Rotate", "Flip", "AVIF", "Merge PDF", "Actions"]; + var roomy = layout.PlanRing(six.Select((n, i) => Slice(n, i, 6, LabelShow.Auto, "crop")).ToList(), 1, 1, false); + Assert.All(roomy, l => Assert.Equal(SliceLabelKind.IconAndText, l.Kind)); + + // Twelve slices where one name can't fit whole: no slice shows its name, every one its icon. + string[] twelve = ["Crop", "Rotate", "Flip", "Remove background", "Trim transparent borders", "Grayscale", "AVIF", + "Merge images", "Merge PDF", "Compress image", "Remove metadata", "Actions"]; + var full = layout.PlanRing(twelve.Select((n, i) => Slice(n, i, 12, LabelShow.Auto, "crop")).ToList(), 1, 1, false); + Assert.Single(full.Select(l => l.Kind).Distinct()); + } + + [AvaloniaFact] + public void Auto_slices_without_an_icon_show_their_name_and_explicit_slices_keep_their_style() + { + var layout = Layout(); + var plan = layout.PlanRing( + [ + Slice("Trim transparent borders", 0, 12, LabelShow.Auto, "crop-free"), + Slice("PNG", 1, 12, LabelShow.Auto), + Slice("Crop", 2, 12, LabelShow.TextOnly, "crop"), + Slice("Crop", 3, 12, LabelShow.IconOnly, "crop"), + ], 1, 1, false); + Assert.Equal(SliceLabelKind.Icon, plan[0].Kind); // its name can't fit whole: the ring's Auto slices go to icons + Assert.Equal(SliceLabelKind.Text, plan[1].Kind); // no icon: its name + Assert.Equal(SliceLabelKind.Text, plan[2].Kind); // Text only stays text + Assert.Equal(SliceLabelKind.Icon, plan[3].Kind); // Icon only stays an icon + Assert.Equal(plan[0].IconSize, plan[3].IconSize); // one icon size for the ring + } + + [AvaloniaFact] + public void An_icon_alone_is_centred_and_an_icon_with_a_name_sits_above_it_upright() + { + var layout = Layout(); + var alone = layout.PlanRing([Slice("Crop", 2, 6, LabelShow.IconOnly, "crop")], 1, 1, false)[0]; + var zone = alone.Zone; + Assert.Equal(zone.At((zone.Inner + zone.Outer) / 2), alone.IconCenter); + + var both = layout.PlanRing([Slice("Crop", 2, 6, LabelShow.IconAndText, "crop")], 1, 1, false)[0]; + Assert.Equal(SliceLabelKind.IconAndText, both.Kind); + Assert.Equal(0, both.Text!.Rotation); + Assert.True(both.IconCenter.Y < both.Text.Anchor.Y); + Assert.Equal(both.IconCenter.X, both.Text.Anchor.X, 6); + } + + [AvaloniaFact] + public void A_group_ring_follows_the_label_scale_as_far_as_its_slices_have_room() + { + // A group's ring at the default donut: a 46 px band, three slices of about 0.26 rad (some 30 px across). + var layout = Layout(); + const double inner = Outer + 13, outer = inner + 46; + var span = DonutGeometry.SubSpan(DonutGeometry.Span(12), 3, (inner + outer) / 2); + SliceLabelInput Ring(string name, int k, LabelShow show) => + new(name, null, "rotate-left", show, new SliceShape(Center, inner, outer, DonutGeometry.SubMidAngle(0.3, k, 3, span), span, Gap)); + foreach (var show in Enum.GetValues()) + { + List ring = [Ring("Left", 0, show), Ring("Right", 1, show), Ring("180°", 2, show)]; + // At 160 % the names don't fit whole at 20.8 px (icons do): a text ring's names step down (PlanRing does + // that itself now, in 0.5 px steps) until every slice shows its whole name; any ring ends with every slice + // showing its name or icon. + if (show == LabelShow.TextOnly) + Assert.All(layout.PlanRing(ring, 1.6, 1, false), l => Assert.True(l.NameWhole && l.Text!.Size < 13 * 1.6)); + var fitted = layout.PlanRingToFit(ring, 1.6, 1, false); + Assert.All(fitted, l => Assert.True(l.Kind == SliceLabelKind.Icon || l.NameWhole, show.ToString())); + var size = fitted.Select(l => l.Text?.Size ?? 0).Max(); + Assert.True(size is 0 or (> 13 and < 13 * 1.6), $"{show}: {size}"); + // At 100 % (or less) it is what PlanRing gives: no step below the scale asked for. + Assert.Equal(layout.PlanRing(ring, 0.8, 1, false).Select(l => (l.Kind, l.Text?.Size, l.IconSize)), + layout.PlanRingToFit(ring, 0.8, 1, false).Select(l => (l.Kind, l.Text?.Size, l.IconSize))); + } + // Room enough: the scale asked for. + List roomy = [Slice("Crop", 0, 6, LabelShow.TextOnly)]; + Assert.Equal(13 * 1.6, layout.PlanRingToFit(roomy, 1.6, 1, false)[0].Text!.Size, 6); + } + + [AvaloniaFact] + public void Sizes_follow_the_label_scale_and_the_donut_with_a_floor() + { + Assert.Equal(13, SliceLabelLayout.NameSizeFor(1, 1)); + Assert.Equal(26, SliceLabelLayout.NameSizeFor(1.25, 1.6), 6); + Assert.Equal(10, SliceLabelLayout.NameSizeFor(0.8, 100 / 150.0)); // 6.9 → the 10 px floor + Assert.Equal(10.5, SliceLabelLayout.CaptionSizeFor(13)); + } + + [AvaloniaFact] + public void A_caption_is_dropped_before_the_name_is() + { + var layout = Layout(); + var roomy = layout.PlanRing([Slice("Crop", 0, 6, caption: "JPG")], 1, 1, showCaptions: true)[0]; + Assert.Equal("JPG", roomy.Text!.Caption); + var off = layout.PlanRing([Slice("Crop", 0, 6, caption: "JPG")], 1, 1, showCaptions: false)[0]; + Assert.Null(off.Text!.Caption); + var same = layout.PlanRing([Slice("PNG", 0, 6, caption: "png")], 1, 1, showCaptions: true)[0]; + Assert.Null(same.Text!.Caption); + } + + [AvaloniaFact] + public void A_name_beside_an_icon_keeps_most_of_its_letters_or_the_whole_ring_shows_icons() + { + var layout = Layout(); + foreach (var count in new[] { 6, 12 }) + { + var plan = layout.PlanRing(Enumerable.Range(0, count) + .Select(i => Slice("Open in Terminal and more words", i, count, LabelShow.IconAndText, "console")).ToList(), 1, 1, false); + var kind = Assert.Single(plan.Select(l => l.Kind).Distinct()); + Assert.Contains(kind, new[] { SliceLabelKind.IconAndText, SliceLabelKind.Icon }); + foreach (var label in plan) + if (label.Text is { Whole: false } text) + Assert.True(Kept(text) >= SliceLabelLayout.MinKept("Open in Terminal and more words"), text.Lines[0]); + Assert.Single(plan.Where(l => l.IconSize > 0).Select(l => l.IconSize).Distinct()); + } + } + + // ───────────────────────── One style and size per ring, never a stub ───────────────────────── + + // The Images donut's back face, as shipped: every slice has an icon but AVIF (a format). + private static readonly string[] Back = + ["Crop", "Rotate", "Flip", "Remove background", "Trim transparent borders", "Grayscale", "AVIF", "Merge images", + "Merge PDF", "Compress image", "Remove metadata", "One ZIP"]; + private static readonly string[] Six = ["Crop", "Rotate", "Flip", "Remove background", "Grayscale", "Compress image"]; + + private static List Face(string[] names, LabelShow show) => names.Select((n, i) => + Slice(n, i, names.Length, show, n == "AVIF" ? null : "crop", n == "Rotate" ? "Group of 3" : n == "Remove background" ? "PNG" : "JPG")).ToList(); + + private static int Kept(LabelText text) => new System.Globalization.StringInfo(text.Lines[0].TrimEnd('…').TrimEnd()).LengthInTextElements; + + [Fact] + public void A_shortened_name_keeps_sixty_percent_of_its_letters_and_every_word_but_the_last() + { + Assert.Equal(19, SliceLabelLayout.MinKept("Trim transparent borders")); // "Trim transparent bo…", not "Tri…" + Assert.Equal(11, SliceLabelLayout.MinKept("Compress image")); // "Compress im…", not "Compres…" + Assert.Equal(6, SliceLabelLayout.MinKept("PDF 병합")); // all of it: "PDF…" would drop a word + Assert.Equal(3, SliceLabelLayout.MinKept("トリミング")); // 60 % of five characters + } + + [AvaloniaTheory] + [InlineData(LabelShow.Auto)] + [InlineData(LabelShow.IconAndText)] + public void No_ring_shows_a_stub_or_mixes_styles(LabelShow show) + { + var layout = Layout(); + foreach (var names in new[] { Back, Six }) + foreach (var labelScale in new[] { 0.8, 1, 1.3, 1.6 }) + { + var slices = Face(names, show); + var plan = layout.PlanRing(slices, labelScale, 1, showCaptions: true); + var where = $"{names.Length} slices ×{labelScale} {show}"; + // The slices with an icon show one style; one without shows its name. + Assert.Single(plan.Where((_, i) => slices[i].IconKey is not null).Select(l => l.Kind).Distinct()); + Assert.All(plan.Where((_, i) => slices[i].IconKey is null), l => Assert.Equal(SliceLabelKind.Text, l.Kind)); + for (var i = 0; i < plan.Count; i++) + if (plan[i].Text is { Whole: false } text) + { + Assert.True(show == LabelShow.IconAndText, $"{where}: Auto shortened \"{names[i]}\""); + Assert.True(Kept(text) >= SliceLabelLayout.MinKept(names[i]), $"{where}: \"{text.Lines[0]}\""); + } + } + } + + [AvaloniaFact] + public void An_icon_stands_in_for_the_caption_and_names_alone_show_captions_on_all_or_none() + { + var layout = Layout(); + foreach (var show in new[] { LabelShow.Auto, LabelShow.IconAndText, LabelShow.TextOnly }) + foreach (var names in new[] { Back, Six }) + { + var plan = layout.PlanRing(Face(names, show), 1, 1, showCaptions: true); + Assert.All(plan.Where(l => l.Kind == SliceLabelKind.IconAndText), l => Assert.Null(l.Text!.Caption)); + // AVIF's caption is its own name: never shown. Every other name alone shows its caption, or none does. + var alone = plan.Where((l, i) => l.Kind == SliceLabelKind.Text && names[i] != "AVIF").ToList(); + var captions = alone.Count(l => l.Text!.Caption is not null); + Assert.True(captions == 0 || captions == alone.Count, $"{names.Length} {show}: {captions} of {alone.Count}"); + } + // Room for them: names alone with their captions. + var six = layout.PlanRing(Face(Six, LabelShow.TextOnly), 0.8, 1, showCaptions: true); + Assert.All(six, l => Assert.NotNull(l.Text!.Caption)); + } + + [AvaloniaFact] + public void A_ring_steps_its_names_down_to_eleven_px_times_the_donut_size_and_never_under_ten() + { + Assert.Equal([13, 12.5, 12, 11.5, 11], SliceLabelLayout.SizesFor(13, 1)); + Assert.Equal(16.5, SliceLabelLayout.SizesFor(20.8, 1.5)[^1]); // 11 × 1.5 + Assert.Equal(10, SliceLabelLayout.SizesFor(13, 0.7)[^1]); // 11 × 0.7 = 7.7 → never under 10 + Assert.Equal([10.4], SliceLabelLayout.SizesFor(10.4, 1)); // already under the floor: as it is + + var layout = Layout(); + foreach (var show in new[] { LabelShow.Auto, LabelShow.IconAndText, LabelShow.TextOnly }) + foreach (var radius in new[] { 150.0, 220 }) + { + var scale = radius / SliceLabelLayout.DefaultRadius; + var slices = Face(Back, show).Select(s => s with + { + Shape = s.Shape with { Inner = Inner * scale, Outer = radius }, + }).ToList(); + var plan = layout.PlanRing(slices, 1, scale, showCaptions: false); + var size = Assert.Single(plan.Where(l => l.Text is not null).Select(l => l.Text!.Size).Distinct()); + Assert.InRange(size, Math.Max(10, 11 * scale) - 1e-9, 13 * scale + 1e-9); + } + } + + [AvaloniaFact] + public void A_ring_runs_all_its_names_one_way() + { + // A group's ring at the default donut, Group ring size 150 %: "Right" alone fits only along its slice; the ring's + // other names then run along theirs too, rather than straight beside it. + var layout = Layout(); + foreach (var (inner, depth) in new[] { (Outer + 13, 46.0), (Outer + 19.5, 69.0) }) + { + var outer = inner + depth; + var span = DonutGeometry.SubSpan(DonutGeometry.Span(12), 3, (inner + outer) / 2); + var ring = new[] { "Left", "Right", "180°" }.Select((n, k) => new SliceLabelInput(n, null, "rotate-left", LabelShow.TextOnly, + new SliceShape(Center, inner, outer, DonutGeometry.SubMidAngle(0.3, k, 3, span), span, Gap))).ToList(); + foreach (var scale in new[] { 1.0, 1.5 }) + { + var plan = layout.PlanRing(ring, 1, scale, false); + var straight = plan.Select(l => Math.Abs(l.Text!.Rotation) < 1e-6).Distinct().ToList(); + Assert.Single(straight); + } + } + foreach (var names in new[] { Back, Six }) + foreach (var labelScale in new[] { 0.8, 1, 1.3, 1.6 }) + { + var plan = layout.PlanRing(Face(names, LabelShow.TextOnly), labelScale, 1, true); + var kinds = plan.Where(l => l.Text is not null && Math.Abs(Math.Cos(SliceLabelLayout.Along(l.Zone.Mid))) < 0.99) + .Select(l => Math.Abs(l.Text!.Rotation) < 1e-6).Distinct().ToList(); + Assert.True(kinds.Count <= 1, $"{names.Length} slices ×{labelScale}"); + } + } + + [AvaloniaFact] + public void Auto_shows_icon_and_name_on_the_six_slice_face_at_100_percent() + { + var plan = Layout().PlanRing(Face(Six, LabelShow.Auto), 1, 1, showCaptions: true); + Assert.All(plan, l => + { + Assert.Equal(SliceLabelKind.IconAndText, l.Kind); + Assert.True(l.NameWhole); + }); + Assert.InRange(plan[0].Text!.Size, 11, 13); // a step down from 13 px is allowed, 11 px at most + } + + [AvaloniaFact] + public void Icon_and_text_on_the_twelve_slice_face_shows_icons_alone_rather_than_cut_names() + { + var slices = Face(Back, LabelShow.IconAndText); + var plan = Layout().PlanRing(slices, 1, 1, showCaptions: true); + for (var i = 0; i < plan.Count; i++) + Assert.Equal(slices[i].IconKey is null ? SliceLabelKind.Text : SliceLabelKind.Icon, plan[i].Kind); + Assert.True(plan[Array.IndexOf(Back, "AVIF")].NameWhole); + } + + [AvaloniaFact] + public void A_donut_lays_its_labels_out_once_not_on_every_frame() + { + TestServices.EnsureInitialized("en"); + var donut = new DonutMenu + { + Items = Enumerable.Range(0, 12).Select(i => new DonutItem { Id = $"s{i}", Label = $"Slice {i}", IconKey = "crop" }).ToList(), + SliceBrush = Brushes.White, + CenterBrush = Brushes.White, + Foreground = Brushes.Black, + }; + var window = new Window { Content = donut, SizeToContent = SizeToContent.WidthAndHeight }; + window.Show(); + void Frames(int count) + { + for (var i = 0; i < count; i++) + { + donut.Advance(1 / 60.0); + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + } + } + Frames(30); + using (window.CaptureRenderedFrame()) { } + var made = donut.PlansMade; + Assert.True(made >= 1); + Frames(30); + using (window.CaptureRenderedFrame()) { } + Assert.Equal(made, donut.PlansMade); + + donut.LabelScale = 1.25; // a setting changes: laid out again + Frames(2); + using (window.CaptureRenderedFrame()) { } + Assert.True(donut.PlansMade > made); + window.Close(); + } +} diff --git a/tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs b/tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs new file mode 100644 index 000000000..8a2fc19d3 --- /dev/null +++ b/tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs @@ -0,0 +1,88 @@ +// The Toolbar page's slice-label settings: each donut keeps its own Label scale and Slice labels, and the page's preview +// donut shows them, with the slices' icons and their presets' own Show settings. + +using Avalonia.Headless.XUnit; +using Filee.App.Services; +using Filee.App.ViewModels.Pages; +using Filee.Core.Conversion; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.App.Tests; + +public class ToolbarLabelSettingsTests +{ + private static ToolbarPageViewModel Page(UserDataStore store) => + new(store, AppHost.Get(), AppHost.Get(), + AppHost.Get(), AppHost.Get(), NullLogger.Instance); + + [AvaloniaFact] + public void Each_donut_keeps_its_own_label_scale_and_slice_labels() + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var snapshot = store.Profiles.Select(p => p.Clone()).ToList(); + try + { + using var vm = Page(store); + vm.SelectedProfile = vm.Profiles.Single(p => p.Profile.Id == "images"); + Assert.Equal(100, vm.LabelScale); + Assert.Equal(LabelShow.Auto, vm.SliceLabels.Value); + Assert.Equal(["Auto", "Icon & text", "Text only", "Icon only"], vm.SliceLabelChoices.Select(c => c.Label)); + + vm.LabelScale = 125; + vm.SliceLabels = vm.SliceLabelChoices.Single(c => c.Value == LabelShow.IconOnly); + var images = store.Profiles.Single(p => p.Id == "images"); + Assert.Equal(1.25, images.LabelScale); + Assert.Equal(LabelShow.IconOnly, images.LabelShow); + Assert.Equal(LabelShow.IconOnly, vm.DefaultShow); + Assert.Equal((1.25).ToString("P0", System.Globalization.CultureInfo.CurrentCulture), vm.LabelScaleText); + + // Another donut has its own; coming back shows this one's again. + vm.SelectedProfile = vm.Profiles.Single(p => p.Profile.Id == "video"); + Assert.Equal(100, vm.LabelScale); + Assert.Equal(LabelShow.Auto, vm.SliceLabels.Value); + Assert.Equal(1, store.Profiles.Single(p => p.Id == "video").LabelScale); + vm.SelectedProfile = vm.Profiles.Single(p => p.Profile.Id == "images"); + Assert.Equal(125, vm.LabelScale); + Assert.Equal(LabelShow.IconOnly, vm.SliceLabels.Value); + + // Auto again is stored as unset, like a donut that never chose. + vm.SliceLabels = vm.SliceLabelChoices[0]; + Assert.Null(images.LabelShow); + } + finally + { + store.Profiles.Clear(); + store.Profiles.AddRange(snapshot); + store.SaveLibrary(); + } + } + + [AvaloniaFact] + public void The_preview_donut_shows_the_slices_icons_and_full_names() + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var crop = store.FindPreset("tool-crop")!; + var before = crop.Show; + try + { + crop.Show = LabelShow.TextOnly; + using var vm = Page(store); + vm.SelectedProfile = vm.Profiles.Single(p => p.Profile.Id == "images"); + vm.EditingBack = true; + var item = vm.DonutItems.Single(i => i.Id == "tool-crop"); + Assert.Equal("crop", item.IconKey); + Assert.Equal(LabelShow.TextOnly, item.Show); + Assert.Equal("Crop", item.FullName); + Assert.Null(vm.DonutItems.Single(i => i.Id == "to-avif").IconKey); + } + finally + { + crop.Show = before; + } + } +} diff --git a/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs b/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs new file mode 100644 index 000000000..21873f128 --- /dev/null +++ b/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs @@ -0,0 +1,131 @@ +// What a slice shows and how large its label is: a preset's icon, short name and Show, a donut's default Show and +// Label scale, how they are saved (and read by an older or newer Filee), and the short name on the donut. + +using System.Text.Json; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class SliceLabelSettingsTests +{ + private sealed class KeyLocalizer(Dictionary strings) : ILocalizer + { + public string Language => "en"; + public string this[string key] => strings.TryGetValue(key, out var text) ? text : key; + public string Format(string key, params object[] args) => key; + public event EventHandler? LanguageChanged { add { } remove { } } + } + + private static Preset RoundTrip(Preset preset) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(new List { preset }, FileeJsonContext.Default.ListPreset), + FileeJsonContext.Default.ListPreset)!.Single(); + + private static ToolbarProfile RoundTrip(ToolbarProfile profile) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(new List { profile }, FileeJsonContext.Default.ListToolbarProfile), + FileeJsonContext.Default.ListToolbarProfile)!.Single(); + + [Fact] + public void A_presets_icon_short_name_and_show_are_saved_and_copied() + { + var preset = new Preset { Id = "p", Icon = "crop", ShortName = "Trim", Show = LabelShow.IconOnly }; + var read = RoundTrip(preset); + Assert.Equal(("crop", "Trim", LabelShow.IconOnly), (read.Icon, read.ShortName, read.Show)); + var copy = preset.Clone(); + Assert.Equal(("crop", "Trim", LabelShow.IconOnly), (copy.Icon, copy.ShortName, copy.Show)); + } + + [Fact] + public void Unset_values_are_left_out_of_the_file_and_read_back_unset() + { + var json = JsonSerializer.Serialize(new List { new() { Id = "p" } }, FileeJsonContext.Default.ListPreset); + Assert.DoesNotContain("\"icon\"", json); + Assert.DoesNotContain("\"shortName\"", json); + Assert.DoesNotContain("\"show\"", json); + var read = JsonSerializer.Deserialize(json, FileeJsonContext.Default.ListPreset)!.Single(); + Assert.Null(read.Icon); + Assert.Null(read.ShortName); + Assert.Null(read.Show); + } + + [Theory] + [InlineData("\"Sideways\"")] // a newer Filee's choice + [InlineData("3")] + [InlineData("{\"a\":1}")] + [InlineData("null")] + public void A_show_this_version_doesnt_know_reads_as_unset_and_the_rest_still_loads(string value) + { + var json = $$"""[{"id":"p","name":"Mine","targetFormat":"png","show":{{value}},"shortName":"M"}]"""; + var read = JsonSerializer.Deserialize(json, FileeJsonContext.Default.ListPreset)!.Single(); + Assert.Null(read.Show); + Assert.Equal(("Mine", "M"), (read.Name, read.ShortName)); + } + + [Fact] + public void A_show_is_read_by_name_whatever_its_case() + { + var json = """[{"id":"p","targetFormat":"png","show":"icononly"}]"""; + Assert.Equal(LabelShow.IconOnly, JsonSerializer.Deserialize(json, FileeJsonContext.Default.ListPreset)!.Single().Show); + } + + [Fact] + public void A_donuts_label_settings_are_saved_and_copied_and_default_to_auto_at_100_percent() + { + var fresh = RoundTrip(new ToolbarProfile { Id = "d" }); + Assert.Null(fresh.LabelShow); + Assert.Equal(1, fresh.LabelScale); + + var profile = new ToolbarProfile { Id = "d", LabelShow = LabelShow.TextOnly, LabelScale = 1.25 }; + var read = RoundTrip(profile); + Assert.Equal((LabelShow.TextOnly, 1.25), (read.LabelShow, read.LabelScale)); + var copy = profile.Clone(); + Assert.Equal((LabelShow.TextOnly, 1.25), (copy.LabelShow, copy.LabelScale)); + } + + [Theory] + [InlineData(1.0, 1.0)] + [InlineData(1.25, 1.25)] + [InlineData(1.27, 1.25)] // onto a step + [InlineData(0.5, 0.8)] // within the range + [InlineData(3.0, 1.6)] + [InlineData(double.NaN, 1.0)] + [InlineData(double.PositiveInfinity, 1.0)] + public void A_label_scale_is_kept_within_the_slider_on_its_steps(double saved, double expected) => + Assert.Equal(expected, ToolbarProfile.ClampLabelScale(saved)); + + [Fact] + public void A_hand_edited_label_scale_is_put_back_in_range_when_the_library_loads() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + store.Profiles[0].LabelScale = 9; + store.SaveLibrary(); + store.SaveSettings(); + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + Assert.Equal(1.6, reloaded.Profiles[0].LabelScale); + } + + [Fact] + public void The_short_name_is_shown_on_the_donut_and_its_ring_but_not_elsewhere() + { + var loc = new KeyLocalizer(new() { ["preset.trim"] = "Trim transparent borders", ["preset.trim.donut"] = "Trim borders" }); + var builtIn = new Preset { NameKey = "preset.trim" }; + Assert.Equal("Trim borders", loc.DonutName(builtIn)); + + builtIn.ShortName = " Trim "; + Assert.Equal("Trim", loc.DonutName(builtIn)); + Assert.Equal("Trim", loc.RingName(builtIn)); + Assert.Equal("Trim transparent borders", loc.DisplayName(builtIn)); + + var mine = new Preset { Name = "My long preset name", ShortName = "Mine" }; + Assert.Equal("Mine", loc.DonutName(mine)); + Assert.Equal("My long preset name", loc.DisplayName(mine)); + + mine.ShortName = " "; + Assert.Equal("My long preset name", loc.DonutName(mine)); + } +} From bdf5f62523cc5f3f2e837aa22acfb50dbb3e32e5 Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Tue, 10 Nov 2026 13:28:05 -0600 Subject: [PATCH 2/6] feat(donut): group ring follows the donut's size, with ring size and spread settings For #871. A group's ring now grows and shrinks with the donut, and Settings > Donut toolbar > Appearance can make it larger or fan it out wider. Until now it was 46 px deep whatever the donut's size. - The ring's gap, depth and narrowest slice, and the window's room for it, follow radius / 150 times Group ring size (75-150 %). At the defaults and radius 150 the ring is the same as before. - Group ring spread (100-300 %) widens each ring slice, up to one turn round the donut. - Ring labels and icons follow the ring's size, and the Label scale as far as the ring has room. Pointer tolerances stay in pixels. - DonutSettings.GroupRingSize and GroupRingSpread default to 1 and read as 1 from older settings, so no schema step; they are clamped on load. - Strings in all 22 catalogs; ARCHITECTURE.md updated. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 13 +- src/Filee.App/Assets/i18n/ar.json | 2 + src/Filee.App/Assets/i18n/bn-BD.json | 2 + src/Filee.App/Assets/i18n/de.json | 2 + src/Filee.App/Assets/i18n/en.json | 2 + src/Filee.App/Assets/i18n/es.json | 2 + src/Filee.App/Assets/i18n/fa.json | 2 + src/Filee.App/Assets/i18n/fr.json | 2 + src/Filee.App/Assets/i18n/hi.json | 2 + src/Filee.App/Assets/i18n/id.json | 2 + src/Filee.App/Assets/i18n/it.json | 2 + src/Filee.App/Assets/i18n/ja.json | 2 + src/Filee.App/Assets/i18n/ko.json | 2 + src/Filee.App/Assets/i18n/ne.json | 2 + src/Filee.App/Assets/i18n/pl.json | 2 + src/Filee.App/Assets/i18n/pt-BR.json | 2 + src/Filee.App/Assets/i18n/pt-PT.json | 2 + src/Filee.App/Assets/i18n/ru.json | 2 + src/Filee.App/Assets/i18n/th.json | 2 + src/Filee.App/Assets/i18n/tr.json | 2 + src/Filee.App/Assets/i18n/vi.json | 2 + src/Filee.App/Assets/i18n/zh-CN.json | 2 + src/Filee.App/Assets/i18n/zh-TW.json | 2 + src/Filee.App/Controls/DonutGeometry.cs | 17 +- src/Filee.App/Controls/DonutMenu.Groups.cs | 41 +++-- src/Filee.App/Controls/DonutMenu.cs | 22 ++- src/Filee.App/Services/RadialController.cs | 2 +- .../ViewModels/Pages/ToolbarPageViewModel.cs | 27 ++- src/Filee.App/ViewModels/RadialViewModel.cs | 8 + src/Filee.App/Views/Pages/ToolbarPage.axaml | 17 ++ src/Filee.App/Views/RadialWindow.axaml | 2 + src/Filee.Core/Settings/AppSettings.cs | 27 +++ src/Filee.Core/Settings/UserDataStore.cs | 2 + tests/Filee.App.Tests/DonutGroupTests.cs | 2 +- tests/Filee.App.Tests/GroupRingSizeTests.cs | 160 ++++++++++++++++++ tests/Filee.App.Tests/SliceLabelFitTests.cs | 59 ++++--- .../GroupRingSettingsTests.cs | 64 +++++++ 37 files changed, 454 insertions(+), 53 deletions(-) create mode 100644 tests/Filee.App.Tests/GroupRingSizeTests.cs create mode 100644 tests/Filee.Core.Tests/GroupRingSettingsTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a1b1b6f0..f42cff62d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -287,6 +287,13 @@ without touching the file's content. - PDFs: ExifTool appends its changes as an update, so earlier values stay recoverable inside the file; the window says so. +A group's ring scales with the donut: its gap, depth and narrowest slice (`DonutMenu.SubGap`, `SubBand`, +`DonutGeometry.MinSubWidth`) and the window's room for it (`SubRingSpaceFor`) are multiplied by the ring scale, +radius ÷ 150 × *Group ring size* (`DonutSettings.GroupRingSize`, 75–150 %), never below the default ring of the +smallest donut (`MinRingScale`). *Group ring spread* (`GroupRingSpread`, 100–300 %) widens each ring slice after the +narrowest-slice rule, up to one turn round the donut. Pointer tolerances (`BackdropMargin`, `LeaveEdge`) stay in +pixels. At the defaults and radius 150 the ring is the same as before these settings. + ### Slice labels `Controls/SliceLabelLayout.cs` decides what each slice shows and where, without drawing; `DonutMenu.DrawSliceLabel` @@ -308,9 +315,9 @@ without an icon (a format conversion) shows its name and doesn't decide the othe boundaries (CJK by character, Korean by word), one way for the ring: straight on every slice, else along every slice (`TextWay`), each through the sizes; beside icons above names, a format's name runs straight. `DonutMenu.LabelPlan` caches each ring's plan by its inputs (items, geometry, scale, settings), so labels are laid out once per change, not per frame; the open, pop and flip animations move them with transforms. -A group's ring doesn't follow the donut's size, so its labels don't either; they follow the Label scale as far as -its slices (about 30 px wide) have room: `PlanRingToFit` steps the scale down 5 % at a time, not below 100 %, until -every slice shows its whole name or its icon. +A group's ring labels follow the ring's scale instead of the donut's, and the Label scale as far as the ring's slices +have room: `PlanRingToFit` steps the Label scale down 5 % at a time, not below 100 %, until every slice shows its +whole name or its icon. The icons are a curated set of Material Design Icons (`SliceIcons.Data.cs`, generated; Apache-2.0, see THIRD-PARTY-NOTICES.md), keyed by their MDI names and grouped for the picker. `Preset.Icon` is a key, `"none"`, or diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 43a1a441d..84c9804f6 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -727,6 +727,8 @@ "toolbar.captions": "إظهار صيغة الناتج", "toolbar.slice_labels": "تسميات القطاعات", "toolbar.label_scale": "حجم التسميات", + "toolbar.ring_size": "حجم حلقة المجموعة", + "toolbar.ring_spread": "اتساع حلقة المجموعة", "labels.auto": "تلقائي", "labels.icon_text": "أيقونة ونص", "labels.text_only": "نص فقط", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index 74a4fd622..f078d577b 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -726,6 +726,8 @@ "toolbar.captions": "আউটপুট ফরম্যাট দেখান", "toolbar.slice_labels": "স্লাইসের লেবেল", "toolbar.label_scale": "লেবেলের আকার", + "toolbar.ring_size": "গ্রুপ রিংয়ের আকার", + "toolbar.ring_spread": "গ্রুপ রিংয়ের বিস্তার", "labels.auto": "স্বয়ংক্রিয়", "labels.icon_text": "আইকন ও টেক্সট", "labels.text_only": "কেবল টেক্সট", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index c83920050..64a53b44f 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -727,6 +727,8 @@ "toolbar.captions": "Ausgabeformat anzeigen", "toolbar.slice_labels": "Segmentbeschriftung", "toolbar.label_scale": "Beschriftungsgröße", + "toolbar.ring_size": "Größe des Gruppenrings", + "toolbar.ring_spread": "Breite des Gruppenrings", "labels.auto": "Automatisch", "labels.icon_text": "Symbol und Text", "labels.text_only": "Nur Text", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index ac013b4ac..e0114573e 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -741,6 +741,8 @@ "toolbar.captions": "Show output format", "toolbar.slice_labels": "Slice labels", "toolbar.label_scale": "Label scale", + "toolbar.ring_size": "Group ring size", + "toolbar.ring_spread": "Group ring spread", "labels.auto": "Auto", "labels.icon_text": "Icon & text", "labels.text_only": "Text only", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index e21d1ec9b..a52e940c0 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -727,6 +727,8 @@ "toolbar.captions": "Mostrar formato de salida", "toolbar.slice_labels": "Etiquetas de las porciones", "toolbar.label_scale": "Tamaño de las etiquetas", + "toolbar.ring_size": "Tamaño del anillo de grupo", + "toolbar.ring_spread": "Amplitud del anillo de grupo", "labels.auto": "Automático", "labels.icon_text": "Icono y texto", "labels.text_only": "Solo texto", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index 73b0a7ee3..6cf9edf82 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -726,6 +726,8 @@ "toolbar.captions": "نمایش فرمت خروجی", "toolbar.slice_labels": "برچسب بخش‌ها", "toolbar.label_scale": "اندازه برچسب‌ها", + "toolbar.ring_size": "اندازه حلقه گروه", + "toolbar.ring_spread": "گستردگی حلقه گروه", "labels.auto": "خودکار", "labels.icon_text": "آیکون و متن", "labels.text_only": "فقط متن", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index f52bc682d..d91888581 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -741,6 +741,8 @@ "toolbar.captions": "Afficher le format de sortie", "toolbar.slice_labels": "Libellés des tranches", "toolbar.label_scale": "Taille des libellés", + "toolbar.ring_size": "Taille de l'anneau de groupe", + "toolbar.ring_spread": "Largeur de l'anneau de groupe", "labels.auto": "Auto", "labels.icon_text": "Icône et texte", "labels.text_only": "Texte seul", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index b14ff7d8f..4b4b7658d 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -727,6 +727,8 @@ "toolbar.captions": "आउटपुट फ़ॉर्मैट दिखाएँ", "toolbar.slice_labels": "स्लाइस लेबल", "toolbar.label_scale": "लेबल का आकार", + "toolbar.ring_size": "समूह रिंग का आकार", + "toolbar.ring_spread": "समूह रिंग का फैलाव", "labels.auto": "अपने आप", "labels.icon_text": "आइकन और टेक्स्ट", "labels.text_only": "सिर्फ़ टेक्स्ट", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 77ea76d02..3eae1d682 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -727,6 +727,8 @@ "toolbar.captions": "Tampilkan format keluaran", "toolbar.slice_labels": "Label irisan", "toolbar.label_scale": "Skala label", + "toolbar.ring_size": "Ukuran cincin grup", + "toolbar.ring_spread": "Sebaran cincin grup", "labels.auto": "Otomatis", "labels.icon_text": "Ikon & teks", "labels.text_only": "Hanya teks", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 1ec447732..994d865d4 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -726,6 +726,8 @@ "toolbar.captions": "Mostra formato di output", "toolbar.slice_labels": "Etichette degli spicchi", "toolbar.label_scale": "Dimensione etichette", +"toolbar.ring_size": "Dimensione anello del gruppo", +"toolbar.ring_spread": "Ampiezza anello del gruppo", "labels.auto": "Auto", "labels.icon_text": "Icona e testo", "labels.text_only": "Solo testo", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index b01705812..85b385927 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -727,6 +727,8 @@ "toolbar.captions": "出力形式を表示", "toolbar.slice_labels": "項目のラベル", "toolbar.label_scale": "ラベルの大きさ", + "toolbar.ring_size": "グループリングの大きさ", + "toolbar.ring_spread": "グループリングの広がり", "labels.auto": "自動", "labels.icon_text": "アイコンとテキスト", "labels.text_only": "テキストのみ", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 36c5a22a1..6d9ed2bb5 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -741,6 +741,8 @@ "toolbar.captions": "출력 형식 표시", "toolbar.slice_labels": "세그먼트 레이블", "toolbar.label_scale": "레이블 크기", + "toolbar.ring_size": "그룹 링 크기", + "toolbar.ring_spread": "그룹 링 펼침 폭", "labels.auto": "자동", "labels.icon_text": "아이콘과 텍스트", "labels.text_only": "텍스트만", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index d0a3a97f0..62a4c8e69 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -727,6 +727,8 @@ "toolbar.captions": "आउटपुट ढाँचा देखाउनुहोस्", "toolbar.slice_labels": "स्लाइस लेबल", "toolbar.label_scale": "लेबलको आकार", + "toolbar.ring_size": "समूह रिङको आकार", + "toolbar.ring_spread": "समूह रिङको फैलावट", "labels.auto": "स्वचालित", "labels.icon_text": "आइकन र पाठ", "labels.text_only": "पाठ मात्र", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 3382ba772..ed9e27673 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -741,6 +741,8 @@ "toolbar.captions": "Pokazuj format wyjściowy", "toolbar.slice_labels": "Etykiety wycinków", "toolbar.label_scale": "Rozmiar etykiet", + "toolbar.ring_size": "Rozmiar pierścienia grupy", + "toolbar.ring_spread": "Rozpiętość pierścienia grupy", "labels.auto": "Automatycznie", "labels.icon_text": "Ikona i tekst", "labels.text_only": "Tylko tekst", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 202b6fc5f..a7d1cfdca 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -741,6 +741,8 @@ "toolbar.captions": "Mostrar formato de saída", "toolbar.slice_labels": "Rótulos das fatias", "toolbar.label_scale": "Tamanho dos rótulos", + "toolbar.ring_size": "Tamanho do anel de grupo", + "toolbar.ring_spread": "Abertura do anel de grupo", "labels.auto": "Automático", "labels.icon_text": "Ícone e texto", "labels.text_only": "Só texto", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index f8735c973..67a96fdaa 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -741,6 +741,8 @@ "toolbar.captions": "Mostrar formato de saída", "toolbar.slice_labels": "Rótulos dos segmentos", "toolbar.label_scale": "Tamanho dos rótulos", + "toolbar.ring_size": "Tamanho do anel de grupo", + "toolbar.ring_spread": "Abertura do anel de grupo", "labels.auto": "Automático", "labels.icon_text": "Ícone e texto", "labels.text_only": "Só texto", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 54fbc896c..108c0e609 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -741,6 +741,8 @@ "toolbar.captions": "Показывать формат результата", "toolbar.slice_labels": "Подписи секторов", "toolbar.label_scale": "Размер подписей", + "toolbar.ring_size": "Размер кольца группы", + "toolbar.ring_spread": "Ширина кольца группы", "labels.auto": "Авто", "labels.icon_text": "Значок и текст", "labels.text_only": "Только текст", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 8f9e180ed..f28a150b4 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -727,6 +727,8 @@ "toolbar.captions": "แสดงฟอร์แมตผลลัพธ์", "toolbar.slice_labels": "ป้ายชิ้นส่วน", "toolbar.label_scale": "ขนาดป้าย", + "toolbar.ring_size": "ขนาดวงแหวนกลุ่ม", + "toolbar.ring_spread": "ความกว้างวงแหวนกลุ่ม", "labels.auto": "อัตโนมัติ", "labels.icon_text": "ไอคอนและข้อความ", "labels.text_only": "ข้อความเท่านั้น", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 1cf48f6f0..2f916ab36 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -727,6 +727,8 @@ "toolbar.captions": "Çıktı biçimini göster", "toolbar.slice_labels": "Dilim etiketleri", "toolbar.label_scale": "Etiket boyutu", + "toolbar.ring_size": "Grup halkasının boyutu", + "toolbar.ring_spread": "Grup halkasının genişliği", "labels.auto": "Otomatik", "labels.icon_text": "Simge ve metin", "labels.text_only": "Yalnızca metin", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 49eba108a..70a8925b2 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -741,6 +741,8 @@ "toolbar.captions": "Hiển thị định dạng đầu ra", "toolbar.slice_labels": "Nhãn của các phần", "toolbar.label_scale": "Cỡ nhãn", + "toolbar.ring_size": "Cỡ vòng nhóm", + "toolbar.ring_spread": "Độ trải của vòng nhóm", "labels.auto": "Tự động", "labels.icon_text": "Biểu tượng và chữ", "labels.text_only": "Chỉ chữ", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index f3c0fab37..457037293 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -741,6 +741,8 @@ "toolbar.captions": "显示输出格式", "toolbar.slice_labels": "扇区标签", "toolbar.label_scale": "标签大小", + "toolbar.ring_size": "分组圆环大小", + "toolbar.ring_spread": "分组圆环展开宽度", "labels.auto": "自动", "labels.icon_text": "图标和文字", "labels.text_only": "仅文字", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 160efd665..174b126dd 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -741,6 +741,8 @@ "toolbar.captions": "顯示輸出格式", "toolbar.slice_labels": "扇形標籤", "toolbar.label_scale": "標籤大小", + "toolbar.ring_size": "群組圓環大小", + "toolbar.ring_spread": "群組圓環展開寬度", "labels.auto": "自動", "labels.icon_text": "圖示和文字", "labels.text_only": "僅文字", diff --git a/src/Filee.App/Controls/DonutGeometry.cs b/src/Filee.App/Controls/DonutGeometry.cs index d1317a4db..bf2a4e79f 100644 --- a/src/Filee.App/Controls/DonutGeometry.cs +++ b/src/Filee.App/Controls/DonutGeometry.cs @@ -73,23 +73,28 @@ public static int IndexAt(double angle, int count) /// How far a group's fan reaches into each neighbouring slice, as a share of a slice. public const double SubOverhang = 0.25; - /// Narrowest a slice of a group's ring gets (along its middle), so its label still fits. + /// + /// Narrowest a slice of a group's ring gets (along its middle), so its label still fits; on a ring of the default + /// size (it is multiplied by the ring's scale). + /// public const double MinSubWidth = 44; /// /// Width of one slice of a group's outer ring: the fan covers the group's slice and a quarter of each neighbour /// (), split evenly, so it stays close to the slice it belongs to. On a crowded donut it - /// widens to keep each slice wide at ; it never goes round - /// more than once (a donut of one slice), where slices would lie on top of each other. + /// widens to keep each slice wide at . Then + /// widens it (Group ring spread). It never goes round more than once (a donut of one + /// slice), where slices would lie on top of each other. /// - public static double SubSpan(double parentSpan, int count, double midRadius = 0) + public static double SubSpan(double parentSpan, int count, double midRadius = 0, double spread = 1, + double minWidth = MinSubWidth) { if (count <= 0) return 0; var span = parentSpan * (1 + 2 * SubOverhang) / count; if (midRadius > 0) - span = Math.Max(span, MinSubWidth / midRadius); - return Math.Min(span, 2 * Math.PI / count); + span = Math.Max(span, minWidth / midRadius); + return Math.Min(span * spread, 2 * Math.PI / count); } /// Centre angle of slice of a fan of , centred on . diff --git a/src/Filee.App/Controls/DonutMenu.Groups.cs b/src/Filee.App/Controls/DonutMenu.Groups.cs index cfab4e660..859adcb51 100644 --- a/src/Filee.App/Controls/DonutMenu.Groups.cs +++ b/src/Filee.App/Controls/DonutMenu.Groups.cs @@ -19,17 +19,33 @@ namespace Filee.App.Controls; public sealed partial class DonutMenu { - /// Gap between a popped-out slice and the ring a group fans out. + /// Gap between a popped-out slice and the ring a group fans out, on a ring of scale 1. private const double SubGap = 6; - /// Thickness of the ring a group fans out. + /// Thickness of the ring a group fans out, on a ring of scale 1. private const double SubBand = 46; - /// Extra room around the donut for the ring a group fans out (and its labels, which reach past it). - public const double SubRingSpace = SubGap + SubBand + 10; + /// + /// How large a group's ring is against the default: it follows the donut's size (radius ÷ 150) times the Group ring + /// size setting, but is never smaller than the default ring on the smallest donut (): + /// smaller, a 10 px name (the floor) no longer fits its slices. Its depth, gap, narrowest slice and labels scale with it. + /// + public static double RingScaleFor(double outerRadius, double ringSize) => + Math.Max(MinRingScale, outerRadius / SliceLabelLayout.DefaultRadius * ringSize); + + /// The smallest ring scale: the default ring on a donut of radius 100. + internal const double MinRingScale = 100 / SliceLabelLayout.DefaultRadius; + + /// + /// Extra room around a donut of for the ring a group fans out (and its labels, which + /// reach past it). + /// + public static double SubRingSpaceFor(double outerRadius, double ringSize) => + (SubGap + SubBand + 10) * RingScaleFor(outerRadius, ringSize); /// Width and height of a donut of , with room for group rings when asked. - public static double SideFor(double outerRadius, bool subRing) => 2 * (outerRadius + PopOut + 14 + (subRing ? SubRingSpace : 0)); + public static double SideFor(double outerRadius, bool subRing, double ringSize = 1) => + 2 * (outerRadius + PopOut + 14 + (subRing ? SubRingSpaceFor(outerRadius, ringSize) : 0)); // Group slices: the one fanned out (-1 none), the one still drawn while its ring folds away, and the hovered // slice of the ring. @@ -64,12 +80,13 @@ public sealed partial class DonutMenu !IsEditMode && _expanded >= 0 && _expanded < Count ? Items![_expanded].Children : null; /// - /// How large a group's ring is against the default: it doesn't follow the donut's size (yet). Its labels follow the - /// Label scale as far as its slices (about 30 px wide) have room (). + /// This donut's ring scale (). Its labels follow it, and the Label scale as far as the + /// ring's slices have room (). /// - private const double RingScale = 1; + private double RingScale => RingScaleFor(OuterRadius, GroupRingSize); - private (double Inner, double Outer) SubRadii => (OuterRadius + PopOut + SubGap, OuterRadius + PopOut + SubGap + SubBand); + internal (double Inner, double Outer) SubRadii => + (OuterRadius + PopOut + SubGap * RingScale, OuterRadius + PopOut + (SubGap + SubBand) * RingScale); /// How far past an open ring (and the gap before it) the pointer still counts as on its way to it. private const double BackdropMargin = 8; @@ -91,7 +108,8 @@ private bool OnWayToRing(Point p) => /// Width of each slice of a ring of . private double SubSpanOf(int count) => - DonutGeometry.SubSpan(DonutGeometry.Span(Count), count, (SubRadii.Inner + SubRadii.Outer) / 2); + DonutGeometry.SubSpan(DonutGeometry.Span(Count), count, (SubRadii.Inner + SubRadii.Outer) / 2, GroupRingSpread, + DonutGeometry.MinSubWidth * RingScale); /// The middle of slice of group 's ring (tests). internal Point SubSliceCenter(int group, int sub) @@ -425,8 +443,7 @@ private void DrawGroupRing(DrawingContext context, Point center, Typeface bold, var transform = Matrix.CreateTranslation(-anchor.X, -anchor.Y) * Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(anchor.X, anchor.Y) * SliceFlipTransform(oldFace, group); - // The ring is drawn at one size whatever the donut's (SubBand, SubGap), so its labels are too: they scale with - // the shape they sit in. + // The ring's labels scale with the ring (RingScale), not the donut: they scale with the shape they sit in. var labels = LabelPlan(children, center, inner, outer, k => (DonutGeometry.SubMidAngle(parentMid, k, children.Count, subSpan), subSpan), RingScale, bold, regular, fitScale: true); using (context.PushTransform(transform)) diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index 267133465..818b6ab49 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -93,6 +93,10 @@ public sealed partial class DonutMenu : Control AvaloniaProperty.Register(nameof(DefaultShow), LabelShow.Auto); public static readonly StyledProperty ShowCaptionsProperty = AvaloniaProperty.Register(nameof(ShowCaptions), true); + public static readonly StyledProperty GroupRingSizeProperty = + AvaloniaProperty.Register(nameof(GroupRingSize), 1); + public static readonly StyledProperty GroupRingSpreadProperty = + AvaloniaProperty.Register(nameof(GroupRingSpread), 1); /// The theme's softness (Styles/Controls.axaml binds SoftRadius): rounds the corners of the slices. public static readonly StyledProperty CornerRadiusProperty = @@ -157,11 +161,12 @@ public sealed partial class DonutMenu : Control static DonutMenu() { - AffectsMeasure(OuterRadiusProperty, ReserveSubRingProperty); + AffectsMeasure(OuterRadiusProperty, ReserveSubRingProperty, GroupRingSizeProperty); AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, IsEditModeProperty, SliceBrushProperty, SliceBorderBrushProperty, CenterBrushProperty, AccentBrushProperty, OnAccentBrushProperty, ForegroundProperty, SecondaryForegroundProperty, SuccessBrushProperty, CornerRadiusProperty, - CenterHintProperty, CenterHintWheelProperty, ShowCaptionsProperty, LabelScaleProperty, DefaultShowProperty); + CenterHintProperty, CenterHintWheelProperty, ShowCaptionsProperty, LabelScaleProperty, DefaultShowProperty, + GroupRingSizeProperty, GroupRingSpreadProperty); ItemsProperty.Changed.AddClassHandler((d, _) => d.OnItemsChanged()); } @@ -212,6 +217,12 @@ public DonutMenu() /// public double LabelScale { get => GetValue(LabelScaleProperty); set => SetValue(LabelScaleProperty, value); } + /// How large a group's ring is, against its size on a donut of this radius (Group ring size, 1 = 100 %). + public double GroupRingSize { get => GetValue(GroupRingSizeProperty); set => SetValue(GroupRingSizeProperty, value); } + + /// How wide a group's ring spreads, against the default fan (Group ring spread, 1 = 100 %). + public double GroupRingSpread { get => GetValue(GroupRingSpreadProperty); set => SetValue(GroupRingSpreadProperty, value); } + /// What a slice shows when its item doesn't say (). public LabelShow DefaultShow { get => GetValue(DefaultShowProperty); set => SetValue(DefaultShowProperty, value); } @@ -445,7 +456,7 @@ public DonutHit HitTest(Point p) protected override Size MeasureOverride(Size availableSize) { - var side = SideFor(OuterRadius, ReserveSubRing || (!IsEditMode && Items?.Any(i => i.IsGroup) == true)); + var side = SideFor(OuterRadius, ReserveSubRing || (!IsEditMode && Items?.Any(i => i.IsGroup) == true), GroupRingSize); return new Size(side, side); } @@ -822,7 +833,7 @@ internal bool Advance(double dt, double? elapsed = null) active |= i == ActiveIndex ? _hover[i].Step(1, dt) : _hover[i].Step(0, dt, 320, CriticalDamping); active |= _centerPulse.Step(0, dt, 260, 12); // A soft bounce opening, like the slices popping out; folding away settles without one. The bounce grows the - // ring by about a pixel, well inside the room kept for it (SubRingSpace). + // ring by about a pixel, well inside the room kept for it (SubRingSpaceFor). active |= _expanded >= 0 ? _expand.Step(1, dt, 320, 24) : _expand.Step(0, dt, 320, CriticalDamping); if (_expanded < 0 && _expand.Value <= 0.001) _shownExpanded = -1; @@ -1201,7 +1212,8 @@ private IReadOnlyList LabelPlan(IReadOnlyList items, Poin { var items = Items ?? []; var n = items.Count; - var center = new Point(OuterRadius + SubRingSpace, OuterRadius + SubRingSpace); + var space = SubRingSpaceFor(OuterRadius, GroupRingSize); + var center = new Point(OuterRadius + space, OuterRadius + space); var face = n == 0 ? [] : LabelPlan(items, center, InnerRadius, OuterRadius, i => (DonutGeometry.MidAngle(i, n), DonutGeometry.Span(n)), DonutScale, bold, regular); var (inner, outer) = SubRadii; diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 8485eeb00..6c846f201 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -817,7 +817,7 @@ private void PlaceAndShow(RadialWindow window, int x, int y, bool activate) var scaling = OperatingSystem.IsMacOS() ? 1 : screen?.Scaling ?? 1; // Window size follows the donut size (SizeToContent); compute it up front to centre on the cursor. - var side = DonutMenu.SideFor(viewModel.OuterRadius, viewModel.ReserveSubRing); + var side = DonutMenu.SideFor(viewModel.OuterRadius, viewModel.ReserveSubRing, viewModel.GroupRingSize); var sidePx = (int)Math.Ceiling(side * scaling); var position = WindowPosition(cursor, screen?.WorkingArea, sidePx); diff --git a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs index 0775fa736..11e7195d6 100644 --- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs @@ -125,6 +125,8 @@ public ToolbarPageViewModel(UserDataStore store, ILocalizer loc, WindowService w HoleRatio = store.Settings.Donut.HoleRatio * 100; SliceOpacity = Math.Clamp(store.Settings.Donut.Opacity, DonutSettings.MinOpacity, 1) * 100; ShowCaptions = store.Settings.Donut.ShowCaptions; + GroupRingSize = DonutSettings.ClampGroupRingSize(store.Settings.Donut.GroupRingSize) * 100; + GroupRingSpread = DonutSettings.ClampGroupRingSpread(store.Settings.Donut.GroupRingSpread) * 100; SliceLabelChoices = [.. LabelShows.Select(v => new Choice(v, loc[LabelShowKey(v)]))]; SliceLabels = SliceLabelChoices[0]; _loading = false; @@ -303,13 +305,32 @@ public bool EditingFront /// The output format under the slices' names (Appearance → Show output format). [ObservableProperty] private bool _showCaptions; + /// How large a group's ring is, in percent (Appearance → Group ring size). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(GroupRingSizeText))] + private double _groupRingSize = 100; + + /// How wide a group's ring spreads, in percent (Appearance → Group ring spread). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(GroupRingSpreadText))] + private double _groupRingSpread = 100; + + /// The ring size as its slider shows it: "125 %". + public string GroupRingSizeText => Percent(GroupRingSize); + + /// The ring spread as its slider shows it: "150 %". + public string GroupRingSpreadText => Percent(GroupRingSpread); + + private static string Percent(double value) => + (Math.Round(value) / 100).ToString("P0", System.Globalization.CultureInfo.CurrentCulture); + /// How large the selected donut's slice names and icons are, in percent (). [ObservableProperty] [NotifyPropertyChangedFor(nameof(LabelScaleText))] private double _labelScale = 100; /// The Label scale as the slider's value shows it: "125 %". - public string LabelScaleText => (Math.Round(LabelScale) / 100).ToString("P0", System.Globalization.CultureInfo.CurrentCulture); + public string LabelScaleText => Percent(LabelScale); /// What the selected donut's slices show when their preset doesn't say (). [ObservableProperty] @@ -481,6 +502,8 @@ private void OnExtensionsSaved() partial void OnHoleRatioChanged(double value) => SaveAppearance(); partial void OnSliceOpacityChanged(double value) => SaveAppearance(); partial void OnShowCaptionsChanged(bool value) => SaveAppearance(); + partial void OnGroupRingSizeChanged(double value) => SaveAppearance(); + partial void OnGroupRingSpreadChanged(double value) => SaveAppearance(); /// The four Show modes, in the order the combo boxes list them. internal static readonly LabelShow[] LabelShows = [LabelShow.Auto, LabelShow.IconAndText, LabelShow.TextOnly, LabelShow.IconOnly]; @@ -525,6 +548,8 @@ private void SaveAppearance() _store.Settings.Donut.HoleRatio = Math.Round(HoleRatio) / 100; _store.Settings.Donut.Opacity = Math.Round(SliceOpacity) / 100; _store.Settings.Donut.ShowCaptions = ShowCaptions; + _store.Settings.Donut.GroupRingSize = DonutSettings.ClampGroupRingSize(GroupRingSize / 100); + _store.Settings.Donut.GroupRingSpread = DonutSettings.ClampGroupRingSpread(GroupRingSpread / 100); _store.SaveSettings(); } diff --git a/src/Filee.App/ViewModels/RadialViewModel.cs b/src/Filee.App/ViewModels/RadialViewModel.cs index 5e217a4b6..e8642441a 100644 --- a/src/Filee.App/ViewModels/RadialViewModel.cs +++ b/src/Filee.App/ViewModels/RadialViewModel.cs @@ -28,6 +28,12 @@ public sealed partial class RadialViewModel(UserDataStore store, ILocalizer loc, /// The output format under the slices' names (Settings → Donut toolbar → Appearance). [ObservableProperty] private bool _showCaptions = true; + /// How large a group's ring is (Appearance → Group ring size). + [ObservableProperty] private double _groupRingSize = 1; + + /// How wide a group's ring spreads (Appearance → Group ring spread). + [ObservableProperty] private double _groupRingSpread = 1; + /// How large the slices' names and icons are (the donut's ). [ObservableProperty] private double _labelScale = 1; @@ -357,6 +363,8 @@ private void ApplyAppearance() HoleRatio = store.Settings.Donut.HoleRatio; SliceOpacity = Math.Clamp(store.Settings.Donut.Opacity, DonutSettings.MinOpacity, 1); ShowCaptions = store.Settings.Donut.ShowCaptions; + GroupRingSize = DonutSettings.ClampGroupRingSize(store.Settings.Donut.GroupRingSize); + GroupRingSpread = DonutSettings.ClampGroupRingSpread(store.Settings.Donut.GroupRingSpread); FlipKey = store.Settings.Donut.EffectiveFlipKey(store.Settings.Triggers); FlipWithWheel = store.Settings.Donut.FlipWithWheel; } diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml b/src/Filee.App/Views/Pages/ToolbarPage.axaml index 5bdfd9efb..f74ebb4d9 100644 --- a/src/Filee.App/Views/Pages/ToolbarPage.axaml +++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml @@ -467,6 +467,23 @@ + + + + + + + + + + + + + + + public bool ShowCaptions { get; set; } = true; + /// + /// How large the ring a group fans out is, against its size on a donut of the same radius before this setting + /// ( to ): its depth, its gap from the donut and its labels. + /// A missing value reads as 1, today's ring, so it needs no schema step. + /// + public double GroupRingSize { get; set; } = 1; + + /// + /// How wide the ring a group fans out spreads, against the default fan (the group's slice and a quarter of each + /// neighbour): to . Wider slices have room for longer + /// names. A missing value reads as 1. + /// + public double GroupRingSpread { get; set; } = 1; + + public const double MinGroupRingSize = 0.75, MaxGroupRingSize = 1.5, GroupRingSizeStep = 0.05; + public const double MinGroupRingSpread = 1, MaxGroupRingSpread = 3, GroupRingSpreadStep = 0.1; + + /// within the allowed range, on a step; 1 for a value that isn't a number. + public static double ClampGroupRingSize(double size) => OnStep(size, MinGroupRingSize, MaxGroupRingSize, GroupRingSizeStep); + + /// within the allowed range, on a step; 1 for a value that isn't a number. + public static double ClampGroupRingSpread(double spread) => + OnStep(spread, MinGroupRingSpread, MaxGroupRingSpread, GroupRingSpreadStep); + + private static double OnStep(double value, double min, double max, double step) => + double.IsFinite(value) ? Math.Round(Math.Round(Math.Clamp(value, min, max) / step) * step, 2) : 1; + /// The flip key unless the user picks another one (Settings → Shortcuts). public const string DefaultFlipKey = "Tab"; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 8789ff4bb..361556f9a 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -292,6 +292,8 @@ public int Import(string path) /// Removes dangling preset references and guarantees at least one mixed-selection profile. private void EnsureConsistency() { + Settings.Donut.GroupRingSize = DonutSettings.ClampGroupRingSize(Settings.Donut.GroupRingSize); + Settings.Donut.GroupRingSpread = DonutSettings.ClampGroupRingSpread(Settings.Donut.GroupRingSpread); var ids = Presets.Select(p => p.Id).ToHashSet(); foreach (var profile in Profiles) { diff --git a/tests/Filee.App.Tests/DonutGroupTests.cs b/tests/Filee.App.Tests/DonutGroupTests.cs index 08867cbe3..361ab5729 100644 --- a/tests/Filee.App.Tests/DonutGroupTests.cs +++ b/tests/Filee.App.Tests/DonutGroupTests.cs @@ -765,7 +765,7 @@ public void Near_a_screen_edge_the_rings_of_groups_stay_on_screen(int x, int y) // The ring a group fans out (labels included) is inside the window, and the window inside the screen. var center = new PixelPoint(position.X + side / 2, position.Y + side / 2); - var ringReach = radius + 7 + DonutMenu.SubRingSpace; + var ringReach = radius + 7 + DonutMenu.SubRingSpaceFor(radius, 1); Assert.True(ringReach <= side / 2.0); Assert.True(center.X - ringReach >= area.X && center.X + ringReach <= area.Right, $"x {center.X}"); Assert.True(center.Y - ringReach >= area.Y && center.Y + ringReach <= area.Bottom, $"y {center.Y}"); diff --git a/tests/Filee.App.Tests/GroupRingSizeTests.cs b/tests/Filee.App.Tests/GroupRingSizeTests.cs new file mode 100644 index 000000000..fdf51a424 --- /dev/null +++ b/tests/Filee.App.Tests/GroupRingSizeTests.cs @@ -0,0 +1,160 @@ +// A group's ring follows the donut's size and the Group ring size and spread settings: at the defaults it is the +// ring of before, at radius 240 it is 1.6 times as deep, the size setting makes it deeper or shallower, the spread +// setting fans it wider, and the window keeps room for it. Clicks still land on its slices. + +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.Views; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class GroupRingSizeTests +{ + private static DonutItem Item(string label) => new() { Id = label, Label = label, IsEnabled = true }; + + private static DonutMenu Donut(double radius = 150, double size = 1, double spread = 1) + { + TestServices.EnsureInitialized("en"); + var donut = new DonutMenu + { + Items = [Item("PNG"), Item("JPG"), new DonutItem { Id = "group", Label = "More", IsEnabled = true, + Children = [Item("WEBP"), Item("PDF"), Item("TIFF")] }, Item("BMP")], + OuterRadius = radius, + GroupRingSize = size, + GroupRingSpread = spread, + }; + var window = new Window { Content = donut, SizeToContent = SizeToContent.WidthAndHeight }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return donut; + } + + /// Opens the group's ring (slice 2), as resting on it does. + private static DonutMenu Opened(DonutMenu donut) + { + var center = new Avalonia.Point(donut.Bounds.Width / 2, donut.Bounds.Height / 2); + var at = DonutGeometry.PointAt(center, donut.OuterRadius * 0.7, DonutGeometry.MidAngle(2, 4)); + donut.SetExternalHit(donut.HitTest(at), at); + donut.ExpandPendingGroup(); + for (var i = 0; i < 60; i++) + donut.Advance(1 / 60.0); + return donut; + } + + [AvaloniaFact] + public void At_the_defaults_the_ring_and_the_window_are_as_before() + { + // Before: the ring 6 px out from a popped slice (7), 46 px deep, and 62 px of room for it round the donut. + var donut = Donut(); + Assert.Equal((150 + 7 + 6.0, 150 + 7 + 52.0), donut.SubRadii); + Assert.Equal(2 * (150 + 7 + 14 + 62.0), DonutMenu.SideFor(150, subRing: true)); + Assert.Equal(2 * (150 + 7 + 14 + 62.0), donut.Bounds.Width); + Assert.Equal(DonutGeometry.SubSpan(DonutGeometry.Span(4), 3, 150 + 7 + 29), + DonutGeometry.SubSpan(DonutGeometry.Span(4), 3, 150 + 7 + 29, spread: 1, minWidth: DonutGeometry.MinSubWidth)); + } + + [AvaloniaFact] + public void The_ring_follows_the_donuts_size_and_the_size_setting() + { + var big = Donut(radius: 240); + Assert.Equal(46 * 1.6, big.SubRadii.Outer - big.SubRadii.Inner, 6); + Assert.Equal(6 * 1.6, big.SubRadii.Inner - 240 - 7, 6); + Assert.InRange(big.Bounds.Width, 2 * (240 + 7 + 14 + 62 * 1.6), 2 * (240 + 7 + 14 + 62 * 1.6) + 1); // whole pixels + + var deep = Donut(size: 1.5); + Assert.Equal(46 * 1.5, deep.SubRadii.Outer - deep.SubRadii.Inner, 6); + var shallow = Donut(size: 0.75); + Assert.Equal(46 * 0.75, shallow.SubRadii.Outer - shallow.SubRadii.Inner, 6); + Assert.True(shallow.Bounds.Width < Donut().Bounds.Width); + + // Never smaller than the default ring of the smallest donut: a 10 px name must still fit. + var smallest = Donut(radius: 100, size: 0.75); + Assert.Equal(46 * 100 / 150.0, smallest.SubRadii.Outer - smallest.SubRadii.Inner, 6); + Assert.Equal(DonutMenu.MinRingScale, DonutMenu.RingScaleFor(100, 0.75)); + } + + [AvaloniaFact] + public void Spread_fans_the_ring_wider_but_never_round_more_than_once() + { + var span = DonutGeometry.Span(12); + var plain = DonutGeometry.SubSpan(span, 3); + Assert.Equal(plain * 2, DonutGeometry.SubSpan(span, 3, spread: 2), 9); + Assert.Equal(2 * Math.PI / 3, DonutGeometry.SubSpan(DonutGeometry.Span(2), 3, spread: 3), 9); + // The narrowest slice scales with the ring; spread widens even a ring held at its narrowest. + Assert.Equal(88 / 200.0, DonutGeometry.SubSpan(0.01, 3, midRadius: 200, minWidth: 88), 9); + Assert.Equal(1.5 * 88 / 200.0, DonutGeometry.SubSpan(0.01, 3, midRadius: 200, spread: 1.5, minWidth: 88), 9); + + // On the donut: its ring's slices are wider, and each is hit where it is drawn. + var wide = Opened(Donut(spread: 2)); + var normal = Donut(); + double Angle(DonutMenu d, int sub) => DonutGeometry.AngleOf(new Avalonia.Point(d.Bounds.Width / 2, d.Bounds.Height / 2), d.SubSliceCenter(2, sub)); + Assert.True(Angle(wide, 1) - Angle(wide, 0) > Angle(normal, 1) - Angle(normal, 0)); + for (var sub = 0; sub < 3; sub++) + Assert.Equal(new DonutHit(DonutHitKind.SubSegment, 2, sub), wide.HitTest(wide.SubSliceCenter(2, sub))); + } + + [AvaloniaFact] + public void A_big_ring_is_hit_where_it_is_drawn() + { + var donut = Opened(Donut(radius: 240, size: 1.5)); + for (var sub = 0; sub < 3; sub++) + Assert.Equal(new DonutHit(DonutHitKind.SubSegment, 2, sub), donut.HitTest(donut.SubSliceCenter(2, sub))); + } + + [AvaloniaFact] + public void The_donut_window_takes_both_settings_and_keeps_room_for_the_ring() + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var (size, spread) = (store.Settings.Donut.GroupRingSize, store.Settings.Donut.GroupRingSpread); + try + { + store.Settings.Donut.GroupRingSize = 1.25; + store.Settings.Donut.GroupRingSpread = 1.5; + var vm = AppHost.Get(); + vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); + vm.FitToThisDonut(); + Assert.Equal((1.25, 1.5), (vm.GroupRingSize, vm.GroupRingSpread)); + var window = new RadialWindow { DataContext = vm }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + Assert.Equal((1.25, 1.5), (window.DonutControl.GroupRingSize, window.DonutControl.GroupRingSpread)); + Assert.Equal(DonutMenu.SideFor(vm.OuterRadius, subRing: true, 1.25), window.DonutControl.Bounds.Width, 6); + window.Close(); + } + finally + { + (store.Settings.Donut.GroupRingSize, store.Settings.Donut.GroupRingSpread) = (size, spread); + } + } + + [AvaloniaFact] + public void The_toolbar_page_shows_and_saves_both_settings() + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var (size, spread) = (store.Settings.Donut.GroupRingSize, store.Settings.Donut.GroupRingSpread); + try + { + using var page = new Filee.App.ViewModels.Pages.ToolbarPageViewModel(store, AppHost.Get(), + AppHost.Get(), AppHost.Get(), AppHost.Get(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + Assert.Equal((100.0, 100.0), (page.GroupRingSize, page.GroupRingSpread)); + page.GroupRingSize = 125; + page.GroupRingSpread = 150; + Assert.Equal((1.25, 1.5), (store.Settings.Donut.GroupRingSize, store.Settings.Donut.GroupRingSpread)); + Assert.Equal((1.25).ToString("P0", System.Globalization.CultureInfo.CurrentCulture), page.GroupRingSizeText); + Assert.Equal((1.5).ToString("P0", System.Globalization.CultureInfo.CurrentCulture), page.GroupRingSpreadText); + } + finally + { + (store.Settings.Donut.GroupRingSize, store.Settings.Donut.GroupRingSpread) = (size, spread); + store.SaveSettings(); + } + } +} diff --git a/tests/Filee.App.Tests/SliceLabelFitTests.cs b/tests/Filee.App.Tests/SliceLabelFitTests.cs index 5c388a2b4..39afbeb48 100644 --- a/tests/Filee.App.Tests/SliceLabelFitTests.cs +++ b/tests/Filee.App.Tests/SliceLabelFitTests.cs @@ -1,10 +1,11 @@ -// Every built-in donut, front and back and every group's ring, at the smallest, default and largest donut, at Label -// scale 80 / 100 / 160 %, in the four Show modes and every language: each label stays inside its slice's safe zone, a -// whole name keeps its words whole (Chinese and Japanese may break between characters), and a ring has one name size -// and one icon size. And never a stub: with Auto or Icon & text, a ring's slices that have an icon show one style, a -// name with an icon is shortened (Icon & text only) to 60 % of its letters at most, an icon never comes with a -// caption, names alone show their captions on all or none, and the face's names are 11 px × the donut's size at least -// (never under 10 px, or as small as the Label scale asks). Layout only, nothing is drawn. +// Every built-in donut, front and back and every group's ring (at three ring sizes and spreads), at the smallest, +// default and largest donut, at Label scale 80 / 100 / 160 %, in the four Show modes and every language: each label +// stays inside its slice's safe zone, a whole name keeps its words whole (Chinese and Japanese may break between +// characters), and a ring has one name size and one icon size. And never a stub: with Auto or Icon & text, a ring's +// slices that have an icon show one style, a name with an icon is shortened (Icon & text only) to 60 % of its letters +// at most, an icon never comes with a caption, names alone show their captions on all or none, and the face's names +// are 11 px × the donut's size at least (never under 10 px, or as small as the Label scale asks). Layout only, nothing +// is drawn. using Avalonia.Headless.XUnit; using Avalonia.Media; @@ -22,6 +23,17 @@ public class SliceLabelFitTests private static readonly double[] Radii = [100, 150, 240]; private static readonly double[] Scales = [0.8, 1, 1.6]; + // Group ring size and spread: the default, the smallest, and the largest spread out widest. + private static readonly (double Size, double Spread)[] Rings = [(1, 1), (0.75, 1), (1.5, 3)]; + + // Every combination of the above and the four Show modes. + private static readonly (double Radius, double Scale, LabelShow Show, (double Size, double Spread) Ring)[] Settings = + [.. from radius in Radii + from scale in Scales + from show in Enum.GetValues() + from ring in Rings + select (radius, scale, show, ring)]; + [AvaloniaFact] public void Every_label_of_every_built_in_donut_fits_its_slice_in_every_language() { @@ -41,23 +53,22 @@ public void Every_label_of_every_built_in_donut_fits_its_slice_in_every_language foreach (var (faceName, ids) in new[] { ("front", profile.PresetIds), ("back", profile.BackPresetIds ?? []) }) { donut.Items = Items(ids, store, loc); - foreach (var radius in Radii) - foreach (var scale in Scales) - foreach (var show in Enum.GetValues()) - { - donut.OuterRadius = radius; - donut.LabelScale = scale; - donut.DefaultShow = show; - var (face, rings) = donut.PlanLabels(bold, regular); - var where = $"{language} {profile.Id} {faceName} r{radius} ×{scale} {show}"; - Check(face, donut.Items, where, problems, show); - var floor = Math.Min(SliceLabelLayout.NameSizeFor(scale, donut.DonutScale), - Math.Max(SliceLabelLayout.MinNameSize, SliceLabelLayout.MinStepNameSize * donut.DonutScale)); - if (face.Select(l => l.Text?.Size).OfType().Any(size => size < floor - 1e-9)) - problems.Add($"{where}: names under {floor:0.#} px"); - for (var g = 0; g < rings.Count; g++) - Check(rings[g], donut.Items[g].Children ?? [], $"{where} ring {donut.Items[g].Id}", problems, show); - } + foreach (var (radius, scale, show, ring) in Settings) + { + donut.OuterRadius = radius; + donut.LabelScale = scale; + donut.DefaultShow = show; + (donut.GroupRingSize, donut.GroupRingSpread) = ring; + var (face, rings) = donut.PlanLabels(bold, regular); + var where = $"{language} {profile.Id} {faceName} r{radius} ×{scale} {show} ring {ring.Size}/{ring.Spread}"; + Check(face, donut.Items, where, problems, show); + var floor = Math.Min(SliceLabelLayout.NameSizeFor(scale, donut.DonutScale), + Math.Max(SliceLabelLayout.MinNameSize, SliceLabelLayout.MinStepNameSize * donut.DonutScale)); + if (face.Select(l => l.Text?.Size).OfType().Any(size => size < floor - 1e-9)) + problems.Add($"{where}: names under {floor:0.#} px"); + for (var g = 0; g < rings.Count; g++) + Check(rings[g], donut.Items[g].Children ?? [], $"{where} ring {donut.Items[g].Id}", problems, show); + } } } } diff --git a/tests/Filee.Core.Tests/GroupRingSettingsTests.cs b/tests/Filee.Core.Tests/GroupRingSettingsTests.cs new file mode 100644 index 000000000..31dc55728 --- /dev/null +++ b/tests/Filee.Core.Tests/GroupRingSettingsTests.cs @@ -0,0 +1,64 @@ +// Group ring size and spread in settings.json: today's ring (100 %) for new settings and for settings from before +// them (no schema step), saved, and kept within their ranges on a step. + +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class GroupRingSettingsTests +{ + private static UserDataStore NewStore(TempDir dir) + { + var store = new UserDataStore(dir.Path); + store.Load(); + return store; + } + + [Fact] + public void New_settings_and_settings_from_before_them_keep_todays_ring() + { + using var dir = new TempDir(); + Assert.Equal((1.0, 1.0), (NewStore(dir).Settings.Donut.GroupRingSize, NewStore(dir).Settings.Donut.GroupRingSpread)); + + using var old = new TempDir(); + File.WriteAllText(Path.Combine(old.Path, "settings.json"), + $$"""{ "schemaVersion": {{AppSettings.CurrentSchemaVersion}}, "donut": { "outerRadius": 180 } }"""); + var store = NewStore(old); + Assert.Equal((1.0, 1.0), (store.Settings.Donut.GroupRingSize, store.Settings.Donut.GroupRingSpread)); + } + + [Fact] + public void Size_and_spread_are_saved_and_read_back_within_their_ranges() + { + using var dir = new TempDir(); + var store = NewStore(dir); + store.Settings.Donut.GroupRingSize = 1.25; + store.Settings.Donut.GroupRingSpread = 1.5; + store.SaveSettings(); + var reloaded = NewStore(dir); + Assert.Equal((1.25, 1.5), (reloaded.Settings.Donut.GroupRingSize, reloaded.Settings.Donut.GroupRingSpread)); + + // Hand-edited out of range: brought back in on load. + File.WriteAllText(Path.Combine(dir.Path, "settings.json"), + $$"""{ "schemaVersion": {{AppSettings.CurrentSchemaVersion}}, "donut": { "groupRingSize": 9, "groupRingSpread": 0.2 } }"""); + var clamped = NewStore(dir); + Assert.Equal((1.5, 1.0), (clamped.Settings.Donut.GroupRingSize, clamped.Settings.Donut.GroupRingSpread)); + } + + [Theory] + [InlineData(0.5, 0.75)] + [InlineData(1.02, 1.0)] + [InlineData(1.33, 1.35)] + [InlineData(2, 1.5)] + [InlineData(double.NaN, 1)] + public void Ring_size_is_clamped_to_a_step(double size, double expected) => + Assert.Equal(expected, DonutSettings.ClampGroupRingSize(size)); + + [Theory] + [InlineData(0.5, 1)] + [InlineData(1.54, 1.5)] + [InlineData(5, 3)] + [InlineData(double.PositiveInfinity, 1)] + public void Ring_spread_is_clamped_to_a_step(double spread, double expected) => + Assert.Equal(expected, DonutSettings.ClampGroupRingSpread(spread)); +} From 234b8006e9e0157beadbc1cc3324047aeb52e58d Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Tue, 10 Nov 2026 13:28:05 -0600 Subject: [PATCH 3/6] feat(donut): names along the ring, a Text direction per preset, and equally wide ring slices For #872. - On a slice wider than it is deep (a group's ring), names bend along the ring, letter by letter and never upside down; deep slices run straight or out from the centre. One direction per ring, chosen from its shape. Scripts whose letters join or reorder (Arabic, Persian, Thai, the Indic scripts) are drawn level with the ring instead. - The preset and group editors get Text direction (Automatic, Straight, Out from the centre, Along the ring) next to Show; a slice with its own direction keeps it. - Icon and name show together wherever there's room: before a bent name on a wide slice, before a name out from the centre on a deep one. - Every group's ring slices are as wide as a group of three's, so Group ring spread looks the same on every group. - Strings in all 22 catalogs. - Tests: SliceLabelReadabilityTests, every built-in donut, its back face and every group's ring over donut sizes, holes, Label scales, ring sizes and spreads, Show modes, directions and six scripts, judging room apart from the layout; it fails where a slice shows less than it has room for, or a ring's names run different ways. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 31 +- src/Filee.App/Assets/i18n/ar.json | 4 + src/Filee.App/Assets/i18n/bn-BD.json | 4 + src/Filee.App/Assets/i18n/de.json | 4 + src/Filee.App/Assets/i18n/en.json | 4 + src/Filee.App/Assets/i18n/es.json | 4 + src/Filee.App/Assets/i18n/fa.json | 4 + src/Filee.App/Assets/i18n/fr.json | 4 + src/Filee.App/Assets/i18n/hi.json | 4 + src/Filee.App/Assets/i18n/id.json | 4 + src/Filee.App/Assets/i18n/it.json | 4 + src/Filee.App/Assets/i18n/ja.json | 4 + src/Filee.App/Assets/i18n/ko.json | 4 + src/Filee.App/Assets/i18n/ne.json | 4 + src/Filee.App/Assets/i18n/pl.json | 4 + src/Filee.App/Assets/i18n/pt-BR.json | 4 + src/Filee.App/Assets/i18n/pt-PT.json | 4 + src/Filee.App/Assets/i18n/ru.json | 4 + src/Filee.App/Assets/i18n/th.json | 4 + src/Filee.App/Assets/i18n/tr.json | 4 + src/Filee.App/Assets/i18n/vi.json | 4 + src/Filee.App/Assets/i18n/zh-CN.json | 4 + src/Filee.App/Assets/i18n/zh-TW.json | 4 + src/Filee.App/Controls/DonutGeometry.cs | 19 +- src/Filee.App/Controls/DonutMenu.cs | 20 +- src/Filee.App/Controls/SliceLabelLayout.cs | 460 +++++++++++++++--- .../ViewModels/Pages/ToolbarPageViewModel.cs | 1 + src/Filee.App/ViewModels/RadialViewModel.cs | 2 + src/Filee.App/ViewModels/SliceLabelFields.cs | 16 +- .../Views/SliceLabelFieldsView.axaml | 19 +- src/Filee.Core/Presets/LabelDirection.cs | 57 +++ src/Filee.Core/Presets/LabelShow.cs | 4 +- src/Filee.Core/Presets/Preset.cs | 5 + tests/Filee.App.Tests/DonutGeometryTests.cs | 15 +- .../Filee.App.Tests/SliceLabelEditorTests.cs | 8 +- tests/Filee.App.Tests/SliceLabelFitTests.cs | 29 +- .../Filee.App.Tests/SliceLabelLayoutTests.cs | 129 ++++- .../SliceLabelReadabilityTests.cs | 237 +++++++++ .../SliceLabelSettingsTests.cs | 22 + 39 files changed, 1057 insertions(+), 105 deletions(-) create mode 100644 src/Filee.Core/Presets/LabelDirection.cs create mode 100644 tests/Filee.App.Tests/SliceLabelReadabilityTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f42cff62d..04efd60e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -304,18 +304,35 @@ scale; names start at `max(10, 13 × LabelScale × s)` px, captions 80 % of that alone (k = LabelScale · s). What a slice shows is its preset's `Show` (`LabelShow`: Auto, IconAndText, TextOnly, IconOnly), else its donut's -`ToolbarProfile.LabelShow`, else Auto. `PlanRing` lays out a whole ring at once: one style for the slices that have an -icon, one name size and one icon size. Each style is tried at every size from the starting one down, 0.5 px at a +`ToolbarProfile.LabelShow`, else Auto. `PlanRing` lays out a whole ring at once: one style for the slices that have +an icon, one name size and one icon size. Each style is tried at every size from the starting one down, 0.5 px at a time, to `max(10, 11 × s)` (`SizesFor`; the icon beside a name shrinks with it, to 16·k), and taken only if every slice fits. Auto: icon above the whole name, else the whole name alone, else icons. Icon & text: icon above the whole name, else above a name shortened to no less than 60 % of its letters and never dropping a word (`MinKept`), else icons alone; never a stub like "Tri…". Text only: whole names, else shortened to `MinKept`, else as far as it takes. An icon stands in for the caption; names alone show their captions on every slice that has one or on none. A slice -without an icon (a format conversion) shows its name and doesn't decide the others' style. Names wrap at word -boundaries (CJK by character, Korean by word), one way for the ring: straight on every slice, else along every -slice (`TextWay`), each through the sizes; beside icons above names, a format's name runs straight. `DonutMenu.LabelPlan` caches each ring's plan by its inputs (items, geometry, scale, settings), -so labels are laid out once per change, not per frame; the open, pop and flip animations move them with transforms. -A group's ring labels follow the ring's scale instead of the donut's, and the Label scale as far as the ring's slices +without an icon (a format conversion) shows its name and doesn't decide the others' style. Icon and name: on a deep +slice the icon above the name, both upright, else (where that can't show the name whole, or for Out from the centre) +the icon before a name running out from the centre; on a wide slice the upright icon before a name bent along the +ring, side by side (a ring is too shallow for one above the other at most sizes), else above it. The icon never +turns. A ring takes one placement for all its icons and names, the one of its way (`ways` in `PlanRing`): straight +under an upright icon, out from the centre after the icon, or bent beside it; a format's name beside them runs that +way too, and a ring bends only if every name of it can (else all run level with it). Names wrap at word boundaries +(CJK by character, Korean by word) and run one way for the whole ring, each way through the sizes, except a slice +whose preset says its own (`Preset.Direction`: Straight, OutFromCentre, AlongRing; `SliceLabelLayout.Tries`): on a +slice deeper than it is wide (the donut's, mostly) straight or out from the centre, then bent; on one wider than deep +(a group ring's, or the donut's with a big hole; `IsWide`, so the ring's size, its spread and the name size decide) +bent along the ring, then straight, then out from the centre. A bent name (`LabelText.Curved`) puts each line on its +own circle and each letter on it, upright, clockwise on the top half and anticlockwise on the bottom +(`CurvedGlyphs`); it fits when its lines stay between the zone's circles and their ends clear the side edges at their +inner edge (`CurvedFits`). Scripts whose letters join or reorder (Arabic, Hebrew, the Indic scripts, Thai, Lao, +Myanmar) aren't bent but drawn level with the ring. A shortened name runs in the direction that keeps the most of it. +`DonutMenu.LabelPlan` caches each ring's plan by its inputs (items, geometry, scale, settings), so labels are laid +out once per change, not per frame; the open, pop and flip animations move them with transforms. Every group's ring +slices are equally wide, whatever the group's size (`DonutGeometry.SubSpan`: a share of the group's slice and a +quarter of each neighbour, as if three slices shared it, times Group ring spread), so a group of six fans out twice +as far as a group of three and the spread looks the same on every group; it never goes round more than once. A +group's ring labels follow the ring's scale instead of the donut's, and the Label scale as far as the ring's slices have room: `PlanRingToFit` steps the Label scale down 5 % at a time, not below 100 %, until every slice shows its whole name or its icon. diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 84c9804f6..543035d21 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "يظهر على الشريط الدائري بدل الاسم", "presets.show": "ما يظهر على القطاع", "presets.show_donut": "إعداد الشريط الدائري", + "presets.direction": "اتجاه النص", + "labels.dir_straight": "مستقيم", + "labels.dir_out": "من المركز إلى الخارج", + "labels.dir_ring": "على طول الحلقة", "icons.cat.files_and_folders": "الملفات والمجلدات", "icons.cat.pictures": "الصور", "icons.cat.crop_and_transform": "القص والتحويل", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index f078d577b..db443bb20 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -804,6 +804,10 @@ "presets.short_name_hint": "নামের বদলে ডোনাটে দেখানো হয়", "presets.show": "স্লাইসে দেখান", "presets.show_donut": "ডোনাটের সেটিং ব্যবহার করুন", + "presets.direction": "টেক্সটের দিক", + "labels.dir_straight": "সোজা", + "labels.dir_out": "কেন্দ্র থেকে বাইরে", + "labels.dir_ring": "রিং বরাবর", "icons.cat.files_and_folders": "ফাইল ও ফোল্ডার", "icons.cat.pictures": "ছবি", "icons.cat.crop_and_transform": "ক্রপ ও রূপান্তর", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 64a53b44f..47fe7abbd 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "Wird im Ringmenü statt des Namens angezeigt", "presets.show": "Im Segment zeigen", "presets.show_donut": "Einstellung des Ringmenüs", + "presets.direction": "Textrichtung", + "labels.dir_straight": "Gerade", + "labels.dir_out": "Von der Mitte nach außen", + "labels.dir_ring": "Entlang des Rings", "icons.cat.files_and_folders": "Dateien und Ordner", "icons.cat.pictures": "Bilder", "icons.cat.crop_and_transform": "Zuschneiden und Drehen", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index e0114573e..2fc6c3180 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "Shown on the donut instead of the name", "presets.show": "Show on the slice", "presets.show_donut": "Use the donut's setting", + "presets.direction": "Text direction", + "labels.dir_straight": "Straight", + "labels.dir_out": "Out from the centre", + "labels.dir_ring": "Along the ring", "icons.cat.files_and_folders": "Files & folders", "icons.cat.pictures": "Pictures", "icons.cat.crop_and_transform": "Crop & transform", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index a52e940c0..b45357c87 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "Se muestra en el menú circular en lugar del nombre", "presets.show": "Mostrar en la porción", "presets.show_donut": "Usar el ajuste del menú circular", + "presets.direction": "Dirección del texto", + "labels.dir_straight": "Recto", + "labels.dir_out": "Desde el centro", + "labels.dir_ring": "A lo largo del anillo", "icons.cat.files_and_folders": "Archivos y carpetas", "icons.cat.pictures": "Imágenes", "icons.cat.crop_and_transform": "Recortar y transformar", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index 6cf9edf82..b7018897d 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -804,6 +804,10 @@ "presets.short_name_hint": "به‌جای نام روی دونات نمایش داده می‌شود", "presets.show": "نمایش روی بخش", "presets.show_donut": "استفاده از تنظیم دونات", + "presets.direction": "جهت متن", + "labels.dir_straight": "صاف", + "labels.dir_out": "از مرکز به بیرون", + "labels.dir_ring": "در امتداد حلقه", "icons.cat.files_and_folders": "فایل‌ها و پوشه‌ها", "icons.cat.pictures": "تصاویر", "icons.cat.crop_and_transform": "برش و تبدیل شکل", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index d91888581..7f10a3094 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "Affiché sur le menu circulaire à la place du nom", "presets.show": "Afficher sur la tranche", "presets.show_donut": "Réglage du menu circulaire", + "presets.direction": "Sens du texte", + "labels.dir_straight": "Droit", + "labels.dir_out": "Depuis le centre", + "labels.dir_ring": "Le long de l'anneau", "icons.cat.files_and_folders": "Fichiers et dossiers", "icons.cat.pictures": "Images", "icons.cat.crop_and_transform": "Recadrer et transformer", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 4b4b7658d..c16d262cb 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "डोनट पर नाम की जगह दिखता है", "presets.show": "स्लाइस पर दिखाएँ", "presets.show_donut": "डोनट की सेटिंग", + "presets.direction": "टेक्स्ट की दिशा", + "labels.dir_straight": "सीधा", + "labels.dir_out": "बीच से बाहर की ओर", + "labels.dir_ring": "रिंग के साथ-साथ", "icons.cat.files_and_folders": "फ़ाइलें और फ़ोल्डर", "icons.cat.pictures": "तस्वीरें", "icons.cat.crop_and_transform": "क्रॉप और बदलाव", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 3eae1d682..df3afb7b8 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "Ditampilkan di donut sebagai ganti nama", "presets.show": "Tampilkan di irisan", "presets.show_donut": "Gunakan pengaturan donut", + "presets.direction": "Arah teks", + "labels.dir_straight": "Lurus", + "labels.dir_out": "Keluar dari tengah", + "labels.dir_ring": "Mengikuti cincin", "icons.cat.files_and_folders": "File & folder", "icons.cat.pictures": "Gambar", "icons.cat.crop_and_transform": "Pangkas & ubah bentuk", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 994d865d4..f0ff337b9 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -804,6 +804,10 @@ "presets.short_name_hint": "Mostrato sul donut al posto del nome", "presets.show": "Mostra sullo spicchio", "presets.show_donut": "Usa l’impostazione del donut", +"presets.direction": "Direzione del testo", +"labels.dir_straight": "Dritto", +"labels.dir_out": "Dal centro verso l’esterno", +"labels.dir_ring": "Lungo l’anello", "icons.cat.files_and_folders": "File e cartelle", "icons.cat.pictures": "Immagini", "icons.cat.crop_and_transform": "Ritaglia e trasforma", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 85b385927..fc028f6c0 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "ドーナツメニューで名前の代わりに表示", "presets.show": "項目に表示", "presets.show_donut": "ドーナツメニューの設定に従う", + "presets.direction": "文字の向き", + "labels.dir_straight": "水平", + "labels.dir_out": "中心から外向き", + "labels.dir_ring": "リングに沿って", "icons.cat.files_and_folders": "ファイルとフォルダー", "icons.cat.pictures": "画像", "icons.cat.crop_and_transform": "切り抜きと変形", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 6d9ed2bb5..e87b1e261 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "도넛에 이름 대신 표시", "presets.show": "세그먼트에 표시", "presets.show_donut": "도넛 설정 따르기", + "presets.direction": "텍스트 방향", + "labels.dir_straight": "가로", + "labels.dir_out": "가운데에서 바깥쪽", + "labels.dir_ring": "링을 따라", "icons.cat.files_and_folders": "파일 및 폴더", "icons.cat.pictures": "그림", "icons.cat.crop_and_transform": "자르기 및 변형", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 62a4c8e69..fd9ebf514 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "डोनटमा नामको सट्टा देखिन्छ", "presets.show": "स्लाइसमा देखाउनुहोस्", "presets.show_donut": "डोनटको सेटिङ", + "presets.direction": "पाठको दिशा", + "labels.dir_straight": "सीधा", + "labels.dir_out": "बीचबाट बाहिरतिर", + "labels.dir_ring": "रिङसँगै", "icons.cat.files_and_folders": "फाइल र फोल्डर", "icons.cat.pictures": "तस्बिरहरू", "icons.cat.crop_and_transform": "क्रप र रूपान्तरण", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index ed9e27673..5f4a2c2db 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "Widoczna na pierścieniu zamiast nazwy", "presets.show": "Pokaż na wycinku", "presets.show_donut": "Ustawienie pierścienia", + "presets.direction": "Kierunek tekstu", + "labels.dir_straight": "Poziomo", + "labels.dir_out": "Od środka na zewnątrz", + "labels.dir_ring": "Wzdłuż pierścienia", "icons.cat.files_and_folders": "Pliki i foldery", "icons.cat.pictures": "Obrazy", "icons.cat.crop_and_transform": "Kadrowanie i obrót", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index a7d1cfdca..7f2bacd60 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "Aparece no menu circular no lugar do nome", "presets.show": "Mostrar na fatia", "presets.show_donut": "Usar a configuração do menu", + "presets.direction": "Direção do texto", + "labels.dir_straight": "Reto", + "labels.dir_out": "Do centro para fora", + "labels.dir_ring": "Ao longo do anel", "icons.cat.files_and_folders": "Arquivos e pastas", "icons.cat.pictures": "Imagens", "icons.cat.crop_and_transform": "Cortar e transformar", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 67a96fdaa..b04d690f4 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "Aparece na barra circular em vez do nome", "presets.show": "Mostrar no segmento", "presets.show_donut": "Usar a definição da barra", + "presets.direction": "Direção do texto", + "labels.dir_straight": "Direito", + "labels.dir_out": "Do centro para fora", + "labels.dir_ring": "Ao longo do anel", "icons.cat.files_and_folders": "Ficheiros e pastas", "icons.cat.pictures": "Imagens", "icons.cat.crop_and_transform": "Recortar e transformar", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 108c0e609..9f5c49978 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "Показывается в круговом меню вместо названия", "presets.show": "Показывать в секторе", "presets.show_donut": "Как в настройках меню", + "presets.direction": "Направление текста", + "labels.dir_straight": "Прямо", + "labels.dir_out": "От центра наружу", + "labels.dir_ring": "Вдоль кольца", "icons.cat.files_and_folders": "Файлы и папки", "icons.cat.pictures": "Изображения", "icons.cat.crop_and_transform": "Кадрирование и поворот", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index f28a150b4..429f23e44 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "แสดงบนโดนัทแทนชื่อ", "presets.show": "แสดงบนชิ้นส่วน", "presets.show_donut": "ใช้การตั้งค่าของโดนัท", + "presets.direction": "ทิศทางข้อความ", + "labels.dir_straight": "แนวนอน", + "labels.dir_out": "จากกลางออกไป", + "labels.dir_ring": "ตามวงแหวน", "icons.cat.files_and_folders": "ไฟล์และโฟลเดอร์", "icons.cat.pictures": "รูปภาพ", "icons.cat.crop_and_transform": "ครอบตัดและแปลงรูป", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 2f916ab36..2133d4278 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -805,6 +805,10 @@ "presets.short_name_hint": "Halkada adın yerine gösterilir", "presets.show": "Dilimde göster", "presets.show_donut": "Halkanın ayarını kullan", + "presets.direction": "Metin yönü", + "labels.dir_straight": "Düz", + "labels.dir_out": "Merkezden dışa", + "labels.dir_ring": "Halka boyunca", "icons.cat.files_and_folders": "Dosyalar ve klasörler", "icons.cat.pictures": "Resimler", "icons.cat.crop_and_transform": "Kırpma ve dönüştürme", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 70a8925b2..56b52a648 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "Hiện trên vòng tròn thay cho tên", "presets.show": "Hiện trên phần", "presets.show_donut": "Dùng cài đặt của vòng tròn", + "presets.direction": "Hướng chữ", + "labels.dir_straight": "Thẳng", + "labels.dir_out": "Từ tâm ra ngoài", + "labels.dir_ring": "Theo vòng", "icons.cat.files_and_folders": "Tệp và thư mục", "icons.cat.pictures": "Ảnh", "icons.cat.crop_and_transform": "Cắt và biến đổi", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 457037293..a38a86981 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "在圆环上代替名称显示", "presets.show": "扇区显示", "presets.show_donut": "使用圆环的设置", + "presets.direction": "文字方向", + "labels.dir_straight": "水平", + "labels.dir_out": "从中心向外", + "labels.dir_ring": "沿着圆环", "icons.cat.files_and_folders": "文件和文件夹", "icons.cat.pictures": "图片", "icons.cat.crop_and_transform": "裁剪和变换", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 174b126dd..ce2dc5826 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -820,6 +820,10 @@ "presets.short_name_hint": "在圓環上取代名稱顯示", "presets.show": "扇形顯示", "presets.show_donut": "使用圓環的設定", + "presets.direction": "文字方向", + "labels.dir_straight": "水平", + "labels.dir_out": "從中心向外", + "labels.dir_ring": "沿著圓環", "icons.cat.files_and_folders": "檔案和資料夾", "icons.cat.pictures": "圖片", "icons.cat.crop_and_transform": "裁切和變形", diff --git a/src/Filee.App/Controls/DonutGeometry.cs b/src/Filee.App/Controls/DonutGeometry.cs index bf2a4e79f..cd3b3c685 100644 --- a/src/Filee.App/Controls/DonutGeometry.cs +++ b/src/Filee.App/Controls/DonutGeometry.cs @@ -80,18 +80,25 @@ public static int IndexAt(double angle, int count) public const double MinSubWidth = 44; /// - /// Width of one slice of a group's outer ring: the fan covers the group's slice and a quarter of each neighbour - /// (), split evenly, so it stays close to the slice it belongs to. On a crowded donut it - /// widens to keep each slice wide at . Then - /// widens it (Group ring spread). It never goes round more than once (a donut of one - /// slice), where slices would lie on top of each other. + /// How many slices share the fan of a group at its narrowest (): every ring slice is as wide as + /// one of this many, so a group of six fans out twice as far as a group of three, and Group ring spread looks the same + /// on every group. + /// + public const int SubReferenceCount = 3; + + /// + /// Width of one slice of a group's outer ring, the same for every group whatever its size: a share of the group's + /// slice and a quarter of each neighbour (), as if slices + /// shared it. On a crowded donut it widens to keep each slice wide at + /// . Then widens it (Group ring spread). It never goes round + /// more than once (a big group, a wide spread), where slices would lie on top of each other. /// public static double SubSpan(double parentSpan, int count, double midRadius = 0, double spread = 1, double minWidth = MinSubWidth) { if (count <= 0) return 0; - var span = parentSpan * (1 + 2 * SubOverhang) / count; + var span = parentSpan * (1 + 2 * SubOverhang) / SubReferenceCount; if (midRadius > 0) span = Math.Max(span, minWidth / midRadius); return Math.Min(span * spread, 2 * Math.PI / count); diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index 818b6ab49..ad3d12760 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -59,6 +59,9 @@ public sealed class DonutItem /// What the slice shows. Null: the donut's . public LabelShow? Show { get; init; } + /// Which way its name runs. Null: whichever reads best there (). + public LabelDirection? Direction { get; init; } + /// A group slice ( set), even one left with nothing to fan out. public bool IsGroup => Children is not null; @@ -1193,7 +1196,8 @@ private IReadOnlyList LabelPlan(IReadOnlyList items, Poin { var (mid, span) = slice(i); return new SliceLabelInput(item.Label, item.Caption, item.IconKey, item.Show ?? DefaultShow, - new SliceShape(center, inner, outer, mid, span, GapPixels, item.IsGroup ? ChevronKeepOut : 0)); + new SliceShape(center, inner, outer, mid, span, GapPixels, item.IsGroup ? ChevronKeepOut : 0), + item.Direction ?? LabelDirection.Auto); }).ToList(); PlansMade++; return _labelPlans[key] = fitScale @@ -1245,6 +1249,20 @@ private void DrawSliceLabel(DrawingContext context, SliceLabel label, bool enabl return; var fonts = layout.Fonts; var at = text.Anchor; + if (text.Curved) + { + // Bent along the ring: each letter on its line's circle, upright to the reader. + foreach (var glyph in layout.CurvedGlyphs(label.Zone, text)) + { + var letter = new FormattedText(KeepKoreanWords(glyph.Text), CultureInfo.CurrentUICulture, fonts.Direction, + fonts.Bold, text.Size, brush); + using (context.PushTransform(Matrix.CreateTranslation(-glyph.Advance / 2, -text.LineHeights[glyph.Line] / 2) * + Matrix.CreateRotation(glyph.Rotation) * + Matrix.CreateTranslation(glyph.At.X, glyph.At.Y))) + context.DrawText(letter, new Point(0, 0)); + } + return; + } using (context.PushTransform(Matrix.CreateTranslation(-at.X, -at.Y) * Matrix.CreateRotation(text.Rotation) * Matrix.CreateTranslation(at.X, at.Y))) { diff --git a/src/Filee.App/Controls/SliceLabelLayout.cs b/src/Filee.App/Controls/SliceLabelLayout.cs index 04d9de063..61480e2f8 100644 --- a/src/Filee.App/Controls/SliceLabelLayout.cs +++ b/src/Filee.App/Controls/SliceLabelLayout.cs @@ -7,8 +7,12 @@ // One size → every name on a ring is drawn at one size: 13 × Label scale × donut scale, stepped down 0.5 px at // a time (to 11 px × donut scale, never under 10 px) until every name fits whole; names wrap at word // breaks (up to 3 lines) and are never split, except Chinese and Japanese. -// Direction → one for the ring: its names are drawn straight, or (where that doesn't fit them all whole) all -// along their slices. +// Direction → one for the ring's names. Automatic: on deep slices (the donut's, mostly) straight, else out +// from the centre, else bent; on wide ones (a group ring's, or the donut's with a big hole) bent +// along the ring, else straight, else out from the centre; each way for every name of the ring. A +// preset can ask for one direction (Straight, Out from the centre, Along the ring), which its slice +// keeps. Scripts whose letters join or reorder (Arabic, Hebrew, the Indic scripts, Thai) aren't +// bent: level with the ring instead. // Show → Auto / Icon & text / Text only / Icon only; one style for the ring's slices that have an icon. // Auto: icon and name, else name, else icon, each only if every name shows whole. Icon & text: icon // and name, a name shortened to 60 % of its letters at most, else the icon alone. No stub ("Tri…"). @@ -121,16 +125,21 @@ internal sealed record LabelFonts(Typeface Bold, Typeface Regular, FlowDirection /// internal sealed record LabelText(IReadOnlyList Lines, IReadOnlyList LineHeights, double Size, string? Caption, double CaptionSize, double CaptionHeight, double Width, double Height, Point Anchor, double Rotation, - bool Whole) + bool Whole, IReadOnlyList? CurvedWidths = null) { + /// + /// Bent along the ring: each line on its own circle round the donut's centre, its letters placed one by one + /// (), each as wide as says. Its middle is + /// ; isn't used. + /// + internal bool Curved => CurvedWidths is not null; + + /// Its outline, for straight text. internal LabelBox Box => new(Anchor, Width, Height, Rotation); -} -/// -/// Which way a name runs: straight, along its slice (out from the centre), or either, straight first. A ring's names all -/// run one way. -/// -internal enum TextWay { Either, Straight, Along } + /// True when all of it lies inside . + internal bool Inside(SafeZone zone) => Curved ? SliceLabelLayout.CurvedFits(zone, this) : zone.Contains(Box); +} /// What a slice shows: nothing, its name, its icon above its name, or its icon. internal enum SliceLabelKind { None, Text, IconAndText, Icon } @@ -141,6 +150,15 @@ internal sealed record SliceLabel(SliceLabelKind Kind, LabelText? Text, string? { internal static readonly SliceLabel Empty = new(SliceLabelKind.None, null, null, default, 0, default); + /// + /// The size its name was laid out at (for an icon alone, the smallest its ring tried), and the scale its icon was + /// (Label scale × the donut's, less beside a stepped-down name); 0 for . + /// + internal double NameSize { get; init; } + + /// + internal double Scale { get; init; } + /// True when the slice's whole name is shown. internal bool NameWhole => Text?.Whole == true; @@ -151,7 +169,8 @@ internal sealed record SliceLabel(SliceLabelKind Kind, LabelText? Text, string? } /// One slice to lay out: its name, caption, icon and Show, and its outline. -internal sealed record SliceLabelInput(string Name, string? Caption, string? IconKey, LabelShow Show, SliceShape Shape); +internal sealed record SliceLabelInput(string Name, string? Caption, string? IconKey, LabelShow Show, SliceShape Shape, + LabelDirection Direction = LabelDirection.Auto); /// Lays out the labels of one ring of slices (the donut's face, or a group's ring). internal sealed class SliceLabelLayout @@ -183,9 +202,6 @@ internal sealed class SliceLabelLayout /// The default donut radius, at which the donut's scale is 1. internal const double DefaultRadius = 150; - /// The ways a ring's names are tried, in order: one for every name of the ring. - private static readonly TextWay[] Ways = [TextWay.Straight, TextWay.Along]; - // Where along a slice's middle line a label is tried: the zone's middle first, then further in and out. private static readonly double[] Offsets = [0, 0.1, -0.1, 0.2, -0.2, 0.3, -0.3, 0.4, -0.4]; @@ -271,6 +287,15 @@ internal IReadOnlyList PlanRing(IReadOnlyList slice var sizes = SizesFor(baseSize, scale); var k = labelScale * scale; var zones = slices.Select(s => SafeZone.Of(s.Shape, scale)).ToArray(); + // The ways the ring's names are tried, one for all of them: on a deep ring straight, out from the centre, then + // bent; on a wide one bent, straight, then out from the centre. A preset's own direction keeps its slice. + LabelDirection[] ways = zones.Length > 0 && IsWide(zones[0]) + ? [LabelDirection.AlongRing, LabelDirection.Straight, LabelDirection.OutFromCentre] + : [LabelDirection.Straight, LabelDirection.OutFromCentre, LabelDirection.AlongRing]; + LabelDirection DirectionOf(int i, LabelDirection way) => slices[i].Direction == LabelDirection.Auto ? way : slices[i].Direction; + // Bent along the ring only if every name of the ring can be (else all level with it). + var ringBends = slices.Where(s => s.Direction == LabelDirection.Auto && s.Name.Length > 0).All(s => CanBend(s.Name)); + bool? BendsOf(int i) => slices[i].Direction == LabelDirection.Auto ? ringBends : null; // An icon beside a smaller name shrinks with it, to no less than the smallest icon drawn alone. double IconFor(double size) => IconWithText * k * Math.Max(IconAloneMin / IconWithText, size / baseSize); string? CaptionOf(SliceLabelInput s) => showCaptions && !string.IsNullOrEmpty(s.Caption) && @@ -280,61 +305,99 @@ internal IReadOnlyList PlanRing(IReadOnlyList slice var wantIcon = new bool[slices.Count]; // The icon and the name, no caption: the icon stands in for it. - SliceLabel? Both(int i, double size, bool shorten) + SliceLabel? Both(int i, double size, bool shorten, LabelDirection way) { var s = slices[i]; var icon = IconFor(size); - var fit = FitIconAndText(zones[i], s.Name, null, size, 0, icon, k, shorten, MinKept(s.Name)); + var fit = FitIconAndText(zones[i], s.Name, null, size, 0, icon, k, shorten, DirectionOf(i, way), MinKept(s.Name), BendsOf(i)); return fit is { } f ? new SliceLabel(SliceLabelKind.IconAndText, f.Text, s.IconKey, f.Icon, icon, zones[i]) : null; } // The name, with its caption when withCaption (null if the caption doesn't fit under it); shortened to minKept // (a caption never goes with a shortened name). - SliceLabel? Name(int i, double size, bool withCaption, bool shorten, TextWay way, int? minKept = null) + SliceLabel? Name(int i, double size, bool withCaption, bool shorten, LabelDirection way, int? minKept = null) { var s = slices[i]; var caption = withCaption ? CaptionOf(s) : null; - var block = FitText(zones[i], s.Name, caption, size, CaptionSizeFor(size), alongToo: true, way); + var block = FitText(zones[i], s.Name, caption, size, CaptionSizeFor(size), alongToo: true, DirectionOf(i, way), BendsOf(i)); if (caption is not null && block?.Caption is null) return null; if (block is null && shorten && caption is null) - block = FitShortened(zones[i], s.Name, size, alongToo: true, minKept ?? MinKept(s.Name), way); + block = FitShortened(zones[i], s.Name, size, alongToo: true, DirectionOf(i, way), minKept ?? MinKept(s.Name), BendsOf(i)); return block is null ? null : new SliceLabel(SliceLabelKind.Text, block, null, default, 0, zones[i]); } // A style for all of group, at the largest size every slice fits; false (nothing set) if none does. A slice // without an icon shows its name beside the others' icons, shortened if need be. bool Take(List group, Func fit) + { + if (Plan(group, fit) is not { } plan) + return false; + for (var j = 0; j < group.Count; j++) + result[group[j]] = plan[j]; + return true; + } + // The labels of group at the largest size every slice fits, or null. + List? Plan(List group, Func fit) { foreach (var size in sizes) { var plan = group.Select(i => fit(i, size)).ToList(); - if (plan.Any(p => p is null)) - continue; - for (var j = 0; j < group.Count; j++) - result[group[j]] = plan[j]; - return true; + if (plan.All(p => p is not null)) + return plan!; + } + return null; + } + // Icons and names, one way for the ring's names (a name without an icon runs that way too), each way through + // the sizes. + bool TakeBoth(List group, bool shorten) + { + if (!group.Any(i => slices[i].IconKey is not null)) + return false; + foreach (var way in ways) + { + // A name without an icon runs the way the names beside icons do: out from the centre beside an icon + // before it, bent beside one on a wide ring that bends, else straight (under an upright icon). + var alone = way == LabelDirection.OutFromCentre ? way + : way == LabelDirection.AlongRing && zones.Length > 0 && IsWide(zones[0]) && ringBends ? way + : LabelDirection.Straight; + if (Take(group, (i, size) => slices[i].IconKey is null ? Name(i, size, false, true, alone) : Both(i, size, shorten, way))) + return true; } return false; } - // Beside icons over their names (upright), a name without an icon runs straight too. - bool TakeBoth(List group, bool shorten) => group.Any(i => slices[i].IconKey is not null) && - Take(group, (i, size) => slices[i].IconKey is null ? Name(i, size, false, true, TextWay.Straight) : Both(i, size, shorten)); // One direction for the ring's names: straight through every size, then along the slices. + // Names alone, one way for the ring: captions on every slice that has one, else on none; of the ways, the one + // that shows the most names whole, then at the largest size, then keeps the most letters (the first way of + // those, in order, when they tie). bool TakeNames(List group, bool shorten) { - foreach (var way in Ways) - // Captions on every slice that has one, else on none. - foreach (var withCaption in group.Any(i => CaptionOf(slices[i]) is not null) ? new[] { true, false } : new[] { false }) - if (Take(group, (i, size) => Name(i, size, withCaption, shorten || slices[i].IconKey is null, way))) - return true; + foreach (var withCaption in group.Any(i => CaptionOf(slices[i]) is not null) ? new[] { true, false } : new[] { false }) + { + List? best = null; + (int Whole, double Size, int Letters) bestScore = default; + foreach (var way in ways) + { + if (Plan(group, (i, size) => Name(i, size, withCaption, shorten || slices[i].IconKey is null, way)) is not { } plan) + continue; + var score = (plan.Count(l => l.NameWhole), plan.Max(l => l.Text!.Size), plan.Sum(l => l.Text!.Lines.Sum(t => t.Length))); + if (best is null || score.CompareTo(bestScore) > 0) + (best, bestScore) = (plan, score); + } + if (best is not null) + { + for (var j = 0; j < group.Count; j++) + result[group[j]] = best[j]; + return true; + } + } return false; } // As far as it takes, at the smallest size, still one way for all of them. void LastResort(List group) { - foreach (var way in Ways) + foreach (var way in ways) { var plan = group.Select(i => Name(i, sizes[^1], false, true, way, 0)).ToList(); - if (plan.All(p => p is not null) || way == Ways[^1]) + if (plan.All(p => p is not null) || way == ways[^1]) { for (var j = 0; j < group.Count; j++) result[group[j]] = plan[j] ?? SliceLabel.Empty; @@ -398,39 +461,45 @@ void Icons(List group) else LastResort(alone); } - return result.Select(r => r ?? SliceLabel.Empty).ToArray(); + // The size each label's name was laid out at (for icons alone, the smallest tried), and the scale of its icon. + return result.Select((r, i) => + { + var size = r?.Text?.Size ?? sizes[^1]; + return (r ?? SliceLabel.Empty with { Zone = zones[i] }) with { NameSize = size, Scale = IconFor(size) / IconWithText }; + }).ToArray(); } /// - /// The name whole (with its caption under it when both fit), in as few lines as it takes, straight or (with - /// ) along the slice; straight when both fit. Null if it doesn't fit whole. + /// The name whole (with its caption under it when both fit), in as few lines as it takes, in the directions + /// allows (; only straight without ). + /// Null if it doesn't fit whole. /// internal LabelText? FitText(SafeZone zone, string name, string? caption, double size, double captionSize, bool alongToo, - TextWay way = TextWay.Either) + LabelDirection direction = LabelDirection.Auto, bool? bends = null) { var units = Units(name); if (units.Count == 0) return null; - var turns = Turns(zone, alongToo, way); - foreach (var withCaption in caption is null ? new[] { false } : new[] { true, false }) - for (var lines = 1; lines <= Math.Min(MaxLines, units.Count); lines++) - { - var text = Split(units, lines, size); - foreach (var turn in turns) - if (Place(zone, text, size, withCaption ? caption : null, captionSize, turn, whole: true) is { } block) - return block; - } + foreach (var turns in Tries(zone, name, alongToo, direction, bends)) + foreach (var withCaption in caption is null ? new[] { false } : new[] { true, false }) + for (var lines = 1; lines <= Math.Min(MaxLines, units.Count); lines++) + { + var text = Split(units, lines, size); + foreach (var turn in turns) + if (Place(zone, text, size, withCaption ? caption : null, captionSize, turn, whole: true) is { } block) + return block; + } return null; } /// - /// The name on one line, shortened with "…" until it fits, keeping text elements at least. - /// Null if it doesn't fit even then. + /// The name on one line, shortened with "…" until it fits, keeping text elements at least, + /// in whichever direction keeps the most of it. Null if it doesn't fit even then. /// - internal LabelText? FitShortened(SafeZone zone, string name, double size, bool alongToo, int minKept = 0, - TextWay way = TextWay.Either) + internal LabelText? FitShortened(SafeZone zone, string name, double size, bool alongToo, + LabelDirection direction = LabelDirection.Auto, int minKept = 0, bool? bends = null) { - var turns = Turns(zone, alongToo, way); + var turns = Tries(zone, name, alongToo, direction, bends).SelectMany(t => t).ToArray(); var elements = TextElements(name.Trim()); for (var keep = elements.Count - 1; keep >= minKept; keep--) { @@ -448,7 +517,8 @@ void Icons(List group) /// whole. Null if they don't fit. /// internal (LabelText Text, Point Icon)? FitIconAndText(SafeZone zone, string name, string? caption, double size, - double captionSize, double iconSize, double scale, bool shorten, int minKept = 1) + double captionSize, double iconSize, double scale, bool shorten, LabelDirection direction = LabelDirection.Auto, + int minKept = 1, bool? bends = null) { var units = Units(name); if (units.Count == 0) @@ -464,27 +534,133 @@ void Icons(List group) for (var keep = elements.Count - 1; keep >= Math.Max(1, minKept); keep--) options.Add(([string.Concat(elements.Take(keep)).TrimEnd() + "…"], null, false)); } - foreach (var (lines, cap, whole) in options) + // On a wide slice an upright stack only fits where the ring runs up and down: the name bends along the ring + // beside (or under) the upright icon instead, on every slice of the ring alike. + var bent = direction is LabelDirection.Auto or LabelDirection.AlongRing && IsWide(zone) && (bends ?? CanBend(name)); + if (bent) { - var (textWidth, heights, captionHeight) = Measure(lines, size, cap, captionSize); - var textHeight = heights.Sum() + captionHeight; - var width = Math.Max(iconSize, textWidth); - var height = iconSize + gap + textHeight; - foreach (var offset in Offsets) + foreach (var (lines, cap, whole) in options) { - var at = zone.At((zone.Inner + zone.Outer) / 2 + offset * zone.Depth); - var icon = new Point(at.X, at.Y - height / 2 + iconSize / 2); - var textAt = new Point(at.X, at.Y + height / 2 - textHeight / 2); - if (zone.Contains(new LabelBox(at, width, height, 0)) && zone.Contains(icon, SliceLabel.IconReach(iconSize))) - return (new LabelText(lines, heights, size, cap, captionSize, captionHeight, textWidth, textHeight, textAt, 0, whole), icon); + var (_, heights, _) = Measure(lines, size, cap, captionSize); + if (cap is null && (FitIconBesideBent(zone, lines, heights, size, captionSize, iconSize, gap, whole) + ?? FitIconOverBent(zone, lines, heights, size, captionSize, iconSize, gap, whole)) is { } fit) + return fit; } + return null; + } + // A deep slice: the icon above its name, both upright; where that can't show the name whole, the icon before a + // name running out from the centre (the only way for Out from the centre). Whole names before shortened ones. + var above = direction != LabelDirection.OutFromCentre; + var before = direction is LabelDirection.Auto or LabelDirection.OutFromCentre; + foreach (var whole in new[] { true, false }) + foreach (var beside in new[] { false, true }) + { + if (beside ? !before : !above) + continue; + foreach (var (lines, cap, isWhole) in options.Where(o => o.Whole == whole)) + { + var (textWidth, heights, captionHeight) = Measure(lines, size, cap, captionSize); + if ((beside + ? FitIconBeforeAlong(zone, lines, heights, textWidth, cap, captionSize, captionHeight, size, iconSize, gap, isWhole) + : FitIconAbove(zone, lines, heights, textWidth, cap, captionSize, captionHeight, size, iconSize, gap, isWhole)) + is { } fit) + return fit; + } + } + return null; + } + + // The icon above its name, both upright, on the slice's middle line. + private static (LabelText Text, Point Icon)? FitIconAbove(SafeZone zone, IReadOnlyList lines, double[] heights, + double textWidth, string? cap, double captionSize, double captionHeight, double size, double iconSize, double gap, bool whole) + { + var textHeight = heights.Sum() + captionHeight; + var width = Math.Max(iconSize, textWidth); + var height = iconSize + gap + textHeight; + foreach (var offset in Offsets) + { + var at = zone.At((zone.Inner + zone.Outer) / 2 + offset * zone.Depth); + var icon = new Point(at.X, at.Y - height / 2 + iconSize / 2); + var textAt = new Point(at.X, at.Y + height / 2 - textHeight / 2); + if (zone.Contains(new LabelBox(at, width, height, 0)) && zone.Contains(icon, SliceLabel.IconReach(iconSize))) + return (new LabelText(lines, heights, size, cap, captionSize, captionHeight, textWidth, textHeight, textAt, 0, whole), icon); + } + return null; + } + + /// + /// The upright icon before a name running out from the centre, both on the slice's middle line: first as read, so + /// the inner of the two on the right half (read outward) and the outer on the left half (read inward). + /// + private static (LabelText Text, Point Icon)? FitIconBeforeAlong(SafeZone zone, IReadOnlyList lines, double[] heights, + double textWidth, string? cap, double captionSize, double captionHeight, double size, double iconSize, double gap, bool whole) + { + var textHeight = heights.Sum() + captionHeight; + var turn = Along(zone.Mid); + var a = (zone.Mid % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI); + var outward = a < Math.PI ? 1 : -1; // which way the name reads along the middle line + var total = iconSize + gap + textWidth; + foreach (var offset in Offsets) + { + var middle = (zone.Inner + zone.Outer) / 2 + offset * zone.Depth; + var icon = zone.At(middle - outward * (total / 2 - iconSize / 2)); + var textAt = zone.At(middle + outward * (total / 2 - textWidth / 2)); + if (zone.Contains(icon, SliceLabel.IconReach(iconSize)) && zone.Contains(new LabelBox(textAt, textWidth, textHeight, turn))) + return (new LabelText(lines, heights, size, cap, captionSize, captionHeight, textWidth, textHeight, textAt, turn, whole), icon); + } + return null; + } + + /// + /// The upright icon before a name bent along the ring, side by side along it (first as read: clockwise on the top + /// half, anticlockwise on the bottom): a group ring is wide and shallow, too shallow at most sizes for one above the + /// other. Null if they don't fit. + /// + private (LabelText Text, Point Icon)? FitIconBesideBent(SafeZone zone, IReadOnlyList lines, double[] heights, + double size, double captionSize, double iconSize, double gap, bool whole) + { + var widths = lines.Select(l => CurvedWidth(l, size)).ToArray(); + var textHeight = heights.Sum(); + var textWidth = widths.Max(); + var total = iconSize + gap + textWidth; + var step = Bottom(zone.Mid) ? -1 : 1; + foreach (var offset in Offsets) + { + var middle = (zone.Inner + zone.Outer) / 2 + offset * zone.Depth; + var icon = DonutGeometry.PointAt(zone.Center, middle, zone.Mid - step * (total / 2 - iconSize / 2) / middle); + var anchor = DonutGeometry.PointAt(zone.Center, middle, zone.Mid + step * (total / 2 - textWidth / 2) / middle); + var text = new LabelText(lines, heights, size, null, captionSize, 0, textWidth, textHeight, anchor, 0, whole, widths); + if (zone.Contains(icon, SliceLabel.IconReach(iconSize)) && CurvedFits(zone, text)) + return (text, icon); + } + return null; + } + + /// + /// The upright icon above a name bent along the ring: above as read, so the icon is the outer of the two on the top + /// half and the inner on the bottom half. Null if they don't fit. + /// + private (LabelText Text, Point Icon)? FitIconOverBent(SafeZone zone, IReadOnlyList lines, double[] heights, + double size, double captionSize, double iconSize, double gap, bool whole) + { + var widths = lines.Select(l => CurvedWidth(l, size)).ToArray(); + var textHeight = heights.Sum(); + var height = iconSize + gap + textHeight; + var up = Bottom(zone.Mid) ? -1 : 1; // towards the top of the label, as read + foreach (var offset in Offsets) + { + var middle = (zone.Inner + zone.Outer) / 2 + offset * zone.Depth; + var iconRadius = middle + up * (height / 2 - iconSize / 2); + var textMiddle = middle - up * (height / 2 - textHeight / 2); + var icon = DonutGeometry.PointAt(zone.Center, iconRadius, zone.Mid); + if (zone.Contains(icon, SliceLabel.IconReach(iconSize)) && CurvedFits(zone, textMiddle, heights, textHeight, widths)) + return (new LabelText(lines, heights, size, null, captionSize, 0, widths.Max(), textHeight, zone.At(textMiddle), 0, + whole, widths), icon); } return null; } // The turns a name is tried in: straight, along the slice, or straight then along. - private static double[] Turns(SafeZone zone, bool alongToo, TextWay way) => - !alongToo || way == TextWay.Straight ? [0] : way == TextWay.Along ? [Along(zone.Mid)] : [0, Along(zone.Mid)]; /// /// The turn that runs text along a slice whose middle is at : outward on the right half, inward @@ -496,11 +672,165 @@ internal static double Along(double mid) return a < Math.PI ? mid - Math.PI / 2 : mid + Math.PI / 2; } + /// + /// The turn that runs text level with the ring, across a slice whose middle is at : at right + /// angles to its middle line, turned round on the bottom half so it is never upside down. + /// + internal static double Across(double mid) + { + var a = (mid % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI); + return a > Math.PI / 2 && a < 3 * Math.PI / 2 ? mid - Math.PI : mid; + } + + /// The turn that stands for text bent along the ring (). + internal const double CurvedTurn = double.NaN; + + /// + /// True for a zone wider (along its middle) than it is deep: a group ring's slice, or the donut's with a big hole. + /// The ring's size, its spread and the name size decide. + /// + internal static bool IsWide(SafeZone zone) => + 2 * zone.HalfSpan * (zone.Inner + zone.Outer) / 2 - 2 * zone.Side > zone.Depth; + + /// + /// The turns a name is tried in, in groups: every line count of one group before the next. Automatic: straight or + /// out from the centre on a deep zone, then bent; bent or straight on a wide one, then out from the centre. A + /// preset's own direction is the only one tried. + /// + internal double[][] Tries(SafeZone zone, string name, bool alongToo, LabelDirection direction, bool? bends = null) + { + if (!alongToo) + return [[0]]; + var along = Along(zone.Mid); + // Letters that join or reorder can't be placed one by one: level with the ring instead. + var bent = bends ?? CanBend(name) ? CurvedTurn : Across(zone.Mid); + return direction switch + { + LabelDirection.Straight => [[0]], + LabelDirection.OutFromCentre => [[along]], + LabelDirection.AlongRing => [[bent]], + _ => IsWide(zone) ? [[bent, 0], [along]] : [[0, along], [bent]], + }; + } + + /// + /// True when can be bent letter by letter: read left to right, in a script whose letters + /// neither join nor reorder (not Arabic, Hebrew, the Indic scripts, Thai, Lao, Myanmar). + /// + internal bool CanBend(string name) => _fonts.Direction == FlowDirection.LeftToRight && + !name.Any(c => c is >= '\u0590' and <= '\u08FF' or >= '\u0900' and <= '\u0EFF' or >= '\u1000' and <= '\u109F' + or >= '\uFB1D' and <= '\uFDFF' or >= '\uFE70' and <= '\uFEFF'); + + /// + /// Where each line of a bent name sits: the radius of its middle. On the top half the first line is the outermost; + /// on the bottom half, read the other way up, the innermost. + /// + private static double LineRadius(SafeZone zone, double middle, IReadOnlyList heights, double height, int line) + { + var before = heights.Take(line).Sum(); + return Bottom(zone.Mid) ? middle - height / 2 + before + heights[line] / 2 : middle + height / 2 - before - heights[line] / 2; + } + + /// True on the bottom half of the donut, where bent text runs the other way round to stay upright. + internal static bool Bottom(double mid) + { + var a = (mid % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI); + return a > Math.PI / 2 && a < 3 * Math.PI / 2; + } + + /// + /// True when a bent name lies inside : between its inner and outer circles, and each line's + /// ends (at the line's inner edge, where the sides are closest) clear of the side edges. + /// + internal static bool CurvedFits(SafeZone zone, LabelText text) => + CurvedFits(zone, Distance(zone.Center, text.Anchor), text.LineHeights, text.Height, text.CurvedWidths!, Shift(zone, text)); + + /// How far round from the zone's middle a bent name is centred (beside an icon): its anchor's angle. + private static double Shift(SafeZone zone, LabelText text) => + Math.IEEERemainder(DonutGeometry.AngleOf(zone.Center, text.Anchor) - zone.Mid, 2 * Math.PI); + + private static bool CurvedFits(SafeZone zone, double middle, IReadOnlyList heights, double height, + IReadOnlyList widths, double shift = 0) + { + if (middle - height / 2 < zone.Inner - 1e-9 || middle + height / 2 > zone.Outer + 1e-9) + return false; + for (var i = 0; i < heights.Count; i++) + { + var radius = LineRadius(zone, middle, heights, height, i); + var reach = widths[i] / radius / 2; + if (zone.HalfSpan >= Math.PI) + { + if (reach > Math.PI) + return false; + continue; + } + var room = zone.HalfSpan - Math.Abs(shift) - reach; + if (room <= 0 || (radius - heights[i] / 2) * Math.Sin(Math.Min(room, Math.PI / 2)) < zone.Side - 1e-9) + return false; + } + return true; + } + + private static double Distance(Point a, Point b) => Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y)); + + /// + /// The letters of a bent name as drawn: each text element, the point its middle sits on, its turn (upright to the + /// reader) and its advance, line by line round the donut's centre. + /// + internal IEnumerable<(string Text, Point At, double Rotation, double Advance, int Line)> CurvedGlyphs(SafeZone zone, LabelText text) + { + var middle = Distance(zone.Center, text.Anchor); + var bottom = Bottom(zone.Mid); + var centre = zone.Mid + Shift(zone, text); + for (var i = 0; i < text.Lines.Count; i++) + { + var radius = LineRadius(zone, middle, text.LineHeights, text.Height, i); + var step = bottom ? -1 : 1; + var angle = centre - step * text.CurvedWidths![i] / radius / 2; + foreach (var element in TextElements(text.Lines[i])) + { + var advance = AdvanceOf(element, text.Size); + angle += step * advance / radius / 2; + yield return (element, DonutGeometry.PointAt(zone.Center, radius, angle), bottom ? angle + Math.PI : angle, advance, i); + angle += step * advance / radius / 2; + } + } + } + + // How far a letter moves the pen: what bent text is laid out with, spaces included. + private readonly Dictionary<(string Text, double Size), double> _advances = new(); + + private double AdvanceOf(string element, double size) + { + if (_advances.TryGetValue((element, size), out var known)) + return known; + if (_advances.Count >= MaxCachedSizes) + _advances.Clear(); + var measured = new FormattedText(element, CultureInfo.CurrentUICulture, _fonts.Direction, _fonts.Bold, size, null); + return _advances[(element, size)] = measured.WidthIncludingTrailingWhitespace; + } + + private double CurvedWidth(string line, double size) => TextElements(line).Sum(e => AdvanceOf(e, size)); + private LabelText? Place(SafeZone zone, IReadOnlyList lines, double size, string? caption, double captionSize, double turn, bool whole) { var (width, heights, captionHeight) = Measure(lines, size, caption, captionSize); var height = heights.Sum() + captionHeight; + if (double.IsNaN(turn)) + { + // Bent: no caption (it would need its own circle), each line as wide as its letters' advances. + if (caption is not null) + return null; + var widths = lines.Select(l => CurvedWidth(l, size)).ToArray(); + foreach (var offset in Offsets) + { + var middle = (zone.Inner + zone.Outer) / 2 + offset * zone.Depth; + if (CurvedFits(zone, middle, heights, height, widths)) + return new LabelText(lines, heights, size, null, captionSize, 0, widths.Max(), height, zone.At(middle), 0, whole, widths); + } + return null; + } foreach (var offset in Offsets) { var at = zone.At((zone.Inner + zone.Outer) / 2 + offset * zone.Depth); diff --git a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs index 11e7195d6..b1161fc81 100644 --- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs @@ -818,6 +818,7 @@ private void Rebuild() FullName = _loc.DisplayName(p), IconKey = SliceIcons.For(p), Show = p.Show, + Direction = p.Direction, }) .ToList(); diff --git a/src/Filee.App/ViewModels/RadialViewModel.cs b/src/Filee.App/ViewModels/RadialViewModel.cs index e8642441a..0d087451f 100644 --- a/src/Filee.App/ViewModels/RadialViewModel.cs +++ b/src/Filee.App/ViewModels/RadialViewModel.cs @@ -301,6 +301,7 @@ DonutItem ItemOf(Preset p, bool caption = true) FullName = loc.DisplayName(p), IconKey = SliceIcons.For(p), Show = p.Show, + Direction = p.Direction, }; } @@ -323,6 +324,7 @@ DonutItem ItemOf(Preset p, bool caption = true) FullName = loc.DisplayName(p), IconKey = SliceIcons.For(p), Show = p.Show, + Direction = p.Direction, }; }).ToList(); return new Face(presets, subPresets, items); diff --git a/src/Filee.App/ViewModels/SliceLabelFields.cs b/src/Filee.App/ViewModels/SliceLabelFields.cs index e46fecf3c..5f80ea4e3 100644 --- a/src/Filee.App/ViewModels/SliceLabelFields.cs +++ b/src/Filee.App/ViewModels/SliceLabelFields.cs @@ -1,5 +1,5 @@ // The slice-label fields of the preset and group editors: the slice's icon (picked in the icon picker), a short name -// for the donut, and what the slice shows. Shared by PresetEditorViewModel and GroupEditorViewModel. +// for the donut, what the slice shows, and which way its name runs. Shared by PresetEditorViewModel and GroupEditorViewModel. using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; @@ -27,6 +27,11 @@ public SliceLabelFields(Preset preset, ILocalizer loc) ShowChoices = [new Choice(null, loc["presets.show_donut"]), .. ToolbarPageViewModel.LabelShows.Select(v => new Choice(v, loc[ToolbarPageViewModel.LabelShowKey(v)]))]; _show = ShowChoices.FirstOrDefault(c => c.Value == preset.Show) ?? ShowChoices[0]; + DirectionChoices = [new Choice(null, loc["labels.auto"]), + new Choice(LabelDirection.Straight, loc["labels.dir_straight"]), + new Choice(LabelDirection.OutFromCentre, loc["labels.dir_out"]), + new Choice(LabelDirection.AlongRing, loc["labels.dir_ring"])]; + _direction = DirectionChoices.FirstOrDefault(c => c.Value == preset.Direction) ?? DirectionChoices[0]; } /// The slice's own icon: null for its default, for none, else a key. @@ -69,6 +74,14 @@ public string ShortNamePlaceholder partial void OnShowChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Show = v); + /// Auto, Straight, Out from the centre, Along the ring. + public IReadOnlyList> DirectionChoices { get; } + + [ObservableProperty] private Choice _direction; + + partial void OnDirectionChanged(Choice? oldValue, Choice newValue) => + Choices.Restore(oldValue, newValue, v => Direction = v); + /// The editor's name box changed: the placeholder follows it. public void NameChanged(string name) { @@ -82,5 +95,6 @@ public void Apply(Preset preset) preset.Icon = Icon; preset.ShortName = string.IsNullOrWhiteSpace(ShortName) ? null : ShortName.Trim(); preset.Show = Show.Value; + preset.Direction = Direction.Value; } } diff --git a/src/Filee.App/Views/SliceLabelFieldsView.axaml b/src/Filee.App/Views/SliceLabelFieldsView.axaml index 360f4ae31..cba9c0302 100644 --- a/src/Filee.App/Views/SliceLabelFieldsView.axaml +++ b/src/Filee.App/Views/SliceLabelFieldsView.axaml @@ -1,5 +1,5 @@ + name for the donut, what the slice shows, and which way its name runs. --> - - - - + + + + + + + + + + diff --git a/src/Filee.Core/Presets/LabelDirection.cs b/src/Filee.Core/Presets/LabelDirection.cs new file mode 100644 index 000000000..107edccce --- /dev/null +++ b/src/Filee.Core/Presets/LabelDirection.cs @@ -0,0 +1,57 @@ +// Which way a donut slice's name runs: whichever reads best (Automatic), or one the preset asks for. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Filee.Core.Presets; + +/// Which way a donut slice's name runs (). +public enum LabelDirection +{ + /// + /// Whichever reads best for the slice's shape: straight, or along its longer side (out from the centre on a deep + /// slice, bent along the ring on a wide one). + /// + Auto, + + /// Level, as on a page. + Straight, + + /// From the centre outward on the right half, inward on the left half. + OutFromCentre, + + /// Bent along the ring, never upside down. + AlongRing, +} + +/// +/// Reads a by name. A value this version doesn't know (a newer Filee's) reads as unset, so +/// the slice picks its own direction instead of the preset file failing to read. +/// +public sealed class LabelDirectionJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override LabelDirection? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + reader.Skip(); + return null; + } + if (reader.TokenType != JsonTokenType.String) + return null; + var text = reader.GetString(); + return Enum.GetNames().FirstOrDefault(n => string.Equals(n, text, StringComparison.OrdinalIgnoreCase)) is { } name + ? Enum.Parse(name) + : null; + } + + public override void Write(Utf8JsonWriter writer, LabelDirection? value, JsonSerializerOptions options) + { + if (value is { } direction) + writer.WriteStringValue(direction.ToString()); + else + writer.WriteNullValue(); + } +} diff --git a/src/Filee.Core/Presets/LabelShow.cs b/src/Filee.Core/Presets/LabelShow.cs index 7c2d857c9..fbfdefb52 100644 --- a/src/Filee.Core/Presets/LabelShow.cs +++ b/src/Filee.Core/Presets/LabelShow.cs @@ -12,8 +12,8 @@ namespace Filee.Core.Presets; public enum LabelShow { /// - /// Whichever fits, one style for every Auto slice of a ring: icon and name if every name fits whole, else the name - /// if every name fits whole, else the icon. + /// Whichever fits, one style for every Auto slice of a ring that has an icon: icon and name if every name fits whole, + /// else the name if every name fits whole, else the icon. A slice without an icon shows its name. /// Auto, diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 3ae7f5acf..31cd208f7 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -162,6 +162,10 @@ public sealed class Preset [JsonConverter(typeof(LabelShowJsonConverter))] public LabelShow? Show { get; set; } + /// Which way its donut slice's name runs. Null: whichever reads best there (). + [JsonConverter(typeof(LabelDirectionJsonConverter))] + public LabelDirection? Direction { get; set; } + /// A conversion or a group. A kind this version doesn't know (from a newer Filee) is read as a group. [JsonConverter(typeof(PresetKindJsonConverter))] public PresetKind Kind { get; set; } = PresetKind.Convert; @@ -423,6 +427,7 @@ public Preset ForSharing() Icon = Icon, ShortName = ShortName, Show = Show, + Direction = Direction, Kind = Kind, Tool = Tool, SubPresetIds = [.. SubPresetIds], diff --git a/tests/Filee.App.Tests/DonutGeometryTests.cs b/tests/Filee.App.Tests/DonutGeometryTests.cs index 73b7166cf..f555f643e 100644 --- a/tests/Filee.App.Tests/DonutGeometryTests.cs +++ b/tests/Filee.App.Tests/DonutGeometryTests.cs @@ -58,12 +58,23 @@ public void A_groups_ring_never_overlaps_itself_and_keeps_room_for_labels() // A donut of just the group: 1.5 turns would put slices on top of each other; it goes round once at most. Assert.Equal(2 * Math.PI, DonutGeometry.SubSpan(DonutGeometry.Span(1), 3, 190) * 3, 6); Assert.Equal(2 * Math.PI, DonutGeometry.SubSpan(DonutGeometry.Span(1), 2) * 2, 6); - // A crowded donut (12 slices, a group of 6): each ring slice stays wide enough for its label. - Assert.Equal(DonutGeometry.MinSubWidth, DonutGeometry.SubSpan(DonutGeometry.Span(12), 6, 190) * 190, 6); + // A crowded donut (20 slices): each ring slice stays wide enough for its label. + Assert.Equal(DonutGeometry.MinSubWidth, DonutGeometry.SubSpan(DonutGeometry.Span(20), 3, 190) * 190, 6); // Roomy enough already: unchanged. Assert.Equal(DonutGeometry.Span(4) * 1.5 / 3, DonutGeometry.SubSpan(DonutGeometry.Span(4), 3, 190), 6); } + [Fact] + public void Every_groups_ring_slices_are_as_wide_whatever_its_size() + { + // A group of six fans out twice as far as a group of three: its slices aren't squeezed into the same fan. + var span = DonutGeometry.Span(10); + Assert.Equal(DonutGeometry.SubSpan(span, 3, 270, 2.1), DonutGeometry.SubSpan(span, 6, 270, 2.1), 9); + Assert.Equal(DonutGeometry.SubSpan(span, 3, 270), DonutGeometry.SubSpan(span, 2, 270), 9); + // Until it would go round more than once. + Assert.Equal(2 * Math.PI / 6, DonutGeometry.SubSpan(DonutGeometry.Span(4), 6, 270, 3), 9); + } + [Fact] public void Hole_ring_and_outside_are_classified() { diff --git a/tests/Filee.App.Tests/SliceLabelEditorTests.cs b/tests/Filee.App.Tests/SliceLabelEditorTests.cs index 2ae242d80..6d3d02313 100644 --- a/tests/Filee.App.Tests/SliceLabelEditorTests.cs +++ b/tests/Filee.App.Tests/SliceLabelEditorTests.cs @@ -82,10 +82,12 @@ public void The_group_editor_saves_the_slice_label_fields_and_the_donut_shows_th var loc = Loc(); var store = AppHost.Get(); var rotate = store.FindPreset("rotate")!; - var before = (rotate.Icon, rotate.ShortName, rotate.Show); + var before = (rotate.Icon, rotate.ShortName, rotate.Show, rotate.Direction); try { var vm = new GroupEditorViewModel(rotate, store, loc); + Assert.Null(vm.Labels.Direction.Value); + vm.Labels.Direction = vm.Labels.DirectionChoices.Single(c => c.Value == LabelDirection.AlongRing); Assert.Equal("Default", vm.Labels.IconText); vm.Labels.IconPicker.PickCommand.Execute(vm.Labels.IconPicker.Sections[0].Icons[0]); vm.Labels.ShortName = "Turn"; @@ -99,10 +101,12 @@ public void The_group_editor_saves_the_slice_label_fields_and_the_donut_shows_th Assert.Equal("Rotate", item.FullName); Assert.Equal(rotate.Icon, item.IconKey); Assert.Equal(LabelShow.TextOnly, item.Show); + Assert.Equal(LabelDirection.AlongRing, rotate.Direction); + Assert.Equal(LabelDirection.AlongRing, item.Direction); } finally { - (rotate.Icon, rotate.ShortName, rotate.Show) = before; + (rotate.Icon, rotate.ShortName, rotate.Show, rotate.Direction) = before; } } diff --git a/tests/Filee.App.Tests/SliceLabelFitTests.cs b/tests/Filee.App.Tests/SliceLabelFitTests.cs index 39afbeb48..6bb128aaa 100644 --- a/tests/Filee.App.Tests/SliceLabelFitTests.cs +++ b/tests/Filee.App.Tests/SliceLabelFitTests.cs @@ -80,7 +80,7 @@ public void Every_label_of_every_built_in_donut_fits_its_slice_in_every_language } // The slices as the live donut gives them (RadialViewModel.ItemOf), without checking them against files. - private static List Items(IEnumerable ids, UserDataStore store, ILocalizer loc) + internal static List Items(IEnumerable ids, UserDataStore store, ILocalizer loc, LabelDirection? direction = null) { DonutItem ItemOf(Preset p, bool ring) => new() { @@ -89,6 +89,7 @@ private static List Items(IEnumerable ids, UserDataStore stor Caption = ring ? null : p.TargetFormat == BuiltInData.SameAsSource ? "JPG/PNG" : FormatLabels.NameOf(loc, p.TargetFormat), FullName = loc.DisplayName(p), IconKey = SliceIcons.For(p), + Direction = direction, }; return ids.Select(store.FindPreset).OfType().Select(p => { @@ -104,6 +105,7 @@ private static List Items(IEnumerable ids, UserDataStore stor FullName = loc.DisplayName(p), IconKey = SliceIcons.For(p), Children = children, + Direction = direction, }; }).ToList(); } @@ -114,16 +116,23 @@ private static void Check(IReadOnlyList ring, IReadOnlyList items[i].IconKey is not null && items[i].Label.Length > 0).Select(l => l.Kind).Distinct().ToList(); if (show is LabelShow.Auto or LabelShow.IconAndText && styles.Count > 1) problems.Add($"{where}: mixed styles {string.Join(", ", styles)}"); - // One direction for the ring's names: straight, or along their slices (at 3 and 9 o'clock the two look alike). - var ways = new HashSet { "straight", "along" }; - foreach (var l in ring.Where(l => l.Text is not null)) + // One direction for the ring's names: straight, along their slices, bent along the ring or level with it (two + // can look alike on some slices). A preset's own direction keeps its slice out of it. + var ways = new HashSet { "straight", "along", "bent", "level" }; + for (var i = 0; i < ring.Count; i++) { - var t = l.Text!; + if (ring[i].Text is not { } t || (items[i].Direction ?? LabelDirection.Auto) != LabelDirection.Auto) + continue; + bool Is(double turn) => Math.Abs(Math.IEEERemainder(t.Rotation - turn, 2 * Math.PI)) < 1e-6; var could = new HashSet(); - if (Math.Abs(Math.IEEERemainder(t.Rotation, 2 * Math.PI)) < 1e-6) - could.Add("straight"); - if (Math.Abs(Math.IEEERemainder(t.Rotation - SliceLabelLayout.Along(l.Zone.Mid), 2 * Math.PI)) < 1e-6) - could.Add("along"); + if (t.Curved) + could.Add("bent"); + else + { + if (Is(0)) could.Add("straight"); + if (Is(SliceLabelLayout.Along(ring[i].Zone.Mid))) could.Add("along"); + if (Is(SliceLabelLayout.Across(ring[i].Zone.Mid))) could.Add("level"); + } ways.IntersectWith(could); } if (ways.Count == 0) @@ -155,7 +164,7 @@ private static void Check(IReadOnlyList ring, IReadOnlyList= -1e-9, $"upside down at {mid:F2}"); + // At right angles to the slice's middle line (which points at mid): its reading direction is mid ± 90°. + Assert.Equal(0, Math.Cos(turn - mid) * Math.Sin(turn - mid), 6); + } + } + + [AvaloniaFact] + public void Text_runs_along_a_slices_longer_side() + { + var layout = Layout(); + // The donut's slices are deeper than they are wide: straight or out from the centre, then bent. + var deep = SafeZone.Of(Shape(2, 12), 1); + Assert.False(SliceLabelLayout.IsWide(deep)); + Assert.Equal([[0, SliceLabelLayout.Along(deep.Mid)], [SliceLabelLayout.CurvedTurn]], + layout.Tries(deep, "Crop", alongToo: true, LabelDirection.Auto)); + // A group ring's slices are wider than they are deep: bent along the ring first. + var wide = SafeZone.Of(RingShape(-0.9), 1); + Assert.True(SliceLabelLayout.IsWide(wide)); + Assert.Equal([[SliceLabelLayout.CurvedTurn, 0], [SliceLabelLayout.Along(wide.Mid)]], + layout.Tries(wide, "Crop", alongToo: true, LabelDirection.Auto)); + // Letters that join can't be bent: level with the ring instead. + Assert.Equal([[SliceLabelLayout.Across(wide.Mid), 0], [SliceLabelLayout.Along(wide.Mid)]], + layout.Tries(wide, "قص", alongToo: true, LabelDirection.Auto)); + // A preset's own direction is the only one tried. + Assert.Equal([[0.0]], layout.Tries(wide, "Crop", true, LabelDirection.Straight)); + Assert.Equal([[SliceLabelLayout.Along(wide.Mid)]], layout.Tries(wide, "Crop", true, LabelDirection.OutFromCentre)); + Assert.Equal([[SliceLabelLayout.CurvedTurn]], layout.Tries(deep, "Crop", true, LabelDirection.AlongRing)); + } + + // A narrow slice of a group ring round a donut of radius 220 (ring size 115 %), its middle at mid: as wide as the + // slices of a group of six were before every group's slices got the same width. + private static SliceShape RingShape(double mid, double span = 0.33) => new(Center, 247, 305, mid, span, Gap); + + [AvaloniaTheory] + [InlineData(-0.9)] + [InlineData(-0.3)] + [InlineData(2.6)] // the bottom half, read the other way up + public void Names_on_a_group_ring_bend_along_it_and_show_whole(double mid) + { + var zone = SafeZone.Of(RingShape(mid, 0.5), 1); + foreach (var name in new[] { "Open in Terminal", "Copy to…", "Move to…", "AirDrop", "Add to Shelf" }) + { + var block = Layout().FitText(zone, name, null, 18.6, 15, alongToo: true); + Assert.True(block is { Whole: true, Curved: true }, $"{name} at {mid}"); + Assert.True(block!.Inside(zone), $"{name} at {mid}"); + } + } + + [AvaloniaFact] + public void Bent_names_keep_more_than_straight_ones_where_the_ring_is_tight() + { + // The slice that cut "Copy to…" to "Cop…": bent, more of it shows; straight it needs the room the curve takes. + var zone = SafeZone.Of(RingShape(-0.9), 1); + var layout = Layout(); + var bent = layout.FitShortened(zone, "Open in Terminal", 18.6, true, LabelDirection.AlongRing)!; + var straight = layout.FitShortened(zone, "Open in Terminal", 18.6, true, LabelDirection.Straight)!; + Assert.True(bent.Lines[0].Length > straight.Lines[0].Length, $"{bent.Lines[0]} / {straight.Lines[0]}"); + } + + [AvaloniaTheory] + [InlineData(-0.9)] + [InlineData(2.6)] + public void Bent_letters_run_round_the_ring_upright(double mid) + { + var zone = SafeZone.Of(RingShape(mid, 0.6), 1); + var layout = Layout(); + var block = layout.FitText(zone, "Copy path", null, 15, 12, alongToo: true, LabelDirection.AlongRing)!; + var glyphs = layout.CurvedGlyphs(zone, block).ToList(); + Assert.Equal("Copy path".Length, glyphs.Count); + foreach (var g in glyphs) + { + // Upright: the letter's top points away from the centre on the top half, towards it on the bottom half. + var outward = DonutGeometry.AngleOf(Center, g.At); + Assert.Equal(0, Math.Sin(g.Rotation - outward - (SliceLabelLayout.Bottom(mid) ? Math.PI : 0)), 6); + } + // Each letter follows the last clockwise on the top half, anticlockwise on the bottom half: left to right as read. + for (var i = 1; i < glyphs.Count; i++) + { + var turn = Math.IEEERemainder(DonutGeometry.AngleOf(Center, glyphs[i].At) - DonutGeometry.AngleOf(Center, glyphs[i - 1].At), 2 * Math.PI); + Assert.True(SliceLabelLayout.Bottom(mid) ? turn < 0 : turn > 0, $"letter {i} at {mid}"); + } + } + [AvaloniaFact] public void Straight_wins_when_both_directions_fit() { @@ -183,7 +272,7 @@ public void Every_name_on_a_ring_is_one_size_and_inside_its_zone() "Merge images", "Merge PDF", "Compress image", "Remove metadata", "Actions"]; var plan = Layout().PlanRing(names.Select((n, i) => Slice(n, i, 12)).ToList(), labelScale: 1, scale: 1, showCaptions: true); Assert.Single(plan.Where(l => l.Text is not null).Select(l => l.Text!.Size).Distinct()); - Assert.All(plan, l => Assert.True(l.Text is null || l.Zone.Contains(l.Text.Box))); + Assert.All(plan, l => Assert.True(l.Text is null || l.Text.Inside(l.Zone))); } [AvaloniaFact] @@ -228,13 +317,47 @@ public void An_icon_alone_is_centred_and_an_icon_with_a_name_sits_above_it_uprig var zone = alone.Zone; Assert.Equal(zone.At((zone.Inner + zone.Outer) / 2), alone.IconCenter); - var both = layout.PlanRing([Slice("Crop", 2, 6, LabelShow.IconAndText, "crop")], 1, 1, false)[0]; + // A deep slice (twelve round the donut, at 12 o'clock): the icon above the name, both upright. + var both = layout.PlanRing([Slice("Crop", 0, 12, LabelShow.IconAndText, "crop")], 1, 1, false)[0]; Assert.Equal(SliceLabelKind.IconAndText, both.Kind); Assert.Equal(0, both.Text!.Rotation); Assert.True(both.IconCenter.Y < both.Text.Anchor.Y); Assert.Equal(both.IconCenter.X, both.Text.Anchor.X, 6); } + [AvaloniaTheory] + [InlineData(-0.9)] + [InlineData(-0.3)] + [InlineData(2.6)] + public void On_a_wide_slice_the_icon_stands_before_a_name_bent_along_the_ring(double mid) + { + // Where an icon above its name would only fit on the slices that run up and down: before it, on every slice. + var zone = SafeZone.Of(RingShape(mid, 0.5), 1); + var layout = Layout(); + var fit = layout.FitIconAndText(zone, "Copy path", null, 18.6, 15, 20 * 1.4, 1.4, shorten: false)!.Value; + Assert.True(fit.Text is { Curved: true, Whole: true }); + Assert.True(fit.Text.Inside(zone)); + Assert.True(zone.Contains(fit.Icon, SliceLabel.IconReach(20 * 1.4))); + // Before the name as read: earlier round the ring (clockwise on the top half, anticlockwise on the bottom). + var turn = Math.IEEERemainder(DonutGeometry.AngleOf(Center, fit.Text.Anchor) - DonutGeometry.AngleOf(Center, fit.Icon), 2 * Math.PI); + Assert.True(SliceLabelLayout.Bottom(mid) ? turn < 0 : turn > 0, $"{turn} at {mid}"); + Assert.Equal(0, Math.Round(Distance(fit.Icon) - Distance(fit.Text.Anchor), 6)); // side by side, on one circle + } + + [AvaloniaFact] + public void Where_the_name_cant_show_whole_under_its_icon_the_icon_stands_before_it_out_from_the_centre() + { + // A deep, narrow slice: "Line art" under its icon would be cut; before it, along the slice, it shows whole. + var zone = SafeZone.Of(new SliceShape(Center, 67, 160, DonutGeometry.MidAngle(2, 12), DonutGeometry.Span(12), Gap), 160 / 150.0); + var layout = Layout(); + var fit = layout.FitIconAndText(zone, "Pixelate", null, 11.1, 9, 20 * 0.85, 0.85, shorten: true)!.Value; + Assert.True(fit.Text.Whole); + Assert.Equal(SliceLabelLayout.Along(zone.Mid), fit.Text.Rotation, 9); + Assert.True(Distance(fit.Icon) < Distance(fit.Text.Anchor)); // first as read: inward on the right half + } + + private static double Distance(Point p) => Math.Sqrt(Math.Pow(p.X - Center.X, 2) + Math.Pow(p.Y - Center.Y, 2)); + [AvaloniaFact] public void A_group_ring_follows_the_label_scale_as_far_as_its_slices_have_room() { diff --git a/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs b/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs new file mode 100644 index 000000000..4c403c22e --- /dev/null +++ b/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs @@ -0,0 +1,237 @@ +// The most readable label, every time: every built-in donut, front and back and every group's ring, over the range of +// donut sizes, holes, Label scales, ring sizes and spreads, Show modes, text directions and scripts (Latin, Cyrillic, +// Japanese, Korean, Arabic). On every slice: +// +// Shown → a slice with a name or an icon shows something. +// Never cut → a name is shortened with "…" only where the direction it runs in (its ring's, or its preset's) has +// no room for it whole. +// Icon & text → a ring's slices asked for icon and name show icons alone only where one of them can't fit both, +// not even with its name shortened as far as allowed (SliceLabelLayout.MinKept): one style a ring. +// One style → the Auto slices of a ring all show the same style, and not a lesser one (icons alone, or names +// without their icons) where every one of them has room for more. +// Bent → only names whose letters can be placed one by one bend along the ring. +// One direction → the names of a ring whose presets leave Text direction on Auto all run one way: straight, out +// from the centre, bent along the ring, or level with it. +// +// Layout only, nothing is drawn. "Room" is judged apart from the layout, from the slice's shape and the name's measured +// size with a margin to spare (Room), so a gap in the layout shows up instead of being agreed with. + +using System.Globalization; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class SliceLabelReadabilityTests +{ + private static readonly string[] Languages = ["en", "de", "ru", "ja", "ko", "ar"]; + + private static readonly (double Radius, double Hole, double Scale, LabelShow Show, double RingSize, double Spread)[] Settings = + [.. from radius in new[] { 110.0, 160, 220 } + from hole in new[] { 0.42, 0.64 } + from scale in new[] { 0.8, 1.15, 1.6 } + from show in new[] { LabelShow.Auto, LabelShow.IconAndText, LabelShow.TextOnly } + from ring in new[] { (1.0, 1.0), (1.5, 1.9), (0.75, 3.0) } + select (radius, hole, scale, show, ring.Item1, ring.Item2)]; + + [AvaloniaFact] + public void Every_label_is_the_most_readable_that_fits_in_every_script() + { + var problems = new List(); + try + { + foreach (var language in Languages) + Sweep(language, null, Settings, problems); + } + finally + { + TestServices.EnsureInitialized("en"); + } + Report(problems); + } + + [AvaloniaFact] + public void A_presets_own_direction_is_followed_and_still_reads_best_in_it() + { + var problems = new List(); + try + { + foreach (var direction in new[] { LabelDirection.Straight, LabelDirection.OutFromCentre, LabelDirection.AlongRing }) + Sweep("en", direction, Settings.Where(s => s.Show != LabelShow.TextOnly || s.Scale == 1.15).ToArray(), problems); + } + finally + { + TestServices.EnsureInitialized("en"); + } + Report(problems); + } + + // Every problem in a file beside the screenshots (only the first 40 in the failure message). + private static void Report(List problems, [System.Runtime.CompilerServices.CallerMemberName] string test = "") + { + File.WriteAllLines(Path.Combine(TestServices.ScreenshotDirectory, $"readability-{test}.txt"), problems); + Assert.True(problems.Count == 0, $"{problems.Count} problems:\n{string.Join("\n", problems.Take(40))}"); + } + + private static void Sweep(string language, LabelDirection? direction, + IReadOnlyList<(double Radius, double Hole, double Scale, LabelShow Show, double RingSize, double Spread)> settings, + List problems) + { + TestServices.EnsureInitialized(language); + var loc = AppHost.Get(); + var store = AppHost.Get(); + var family = SoftPalette.FontFamilyFor(language); + var bold = new Typeface(family, FontStyle.Normal, FontWeight.SemiBold); + var regular = new Typeface(family); + var layout = new SliceLabelLayout(new LabelFonts(bold, regular, + CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight)); + var donut = new DonutMenu { ShowCaptions = false }; + foreach (var profile in BuiltInData.CreateProfiles()) + foreach (var (faceName, ids) in new[] { ("front", profile.PresetIds), ("back", profile.BackPresetIds ?? []) }) + { + donut.Items = SliceLabelFitTests.Items(ids, store, loc, direction); + foreach (var (radius, hole, scale, show, ringSize, spread) in settings) + { + (donut.OuterRadius, donut.HoleRatio, donut.LabelScale, donut.DefaultShow) = (radius, hole, scale, show); + (donut.GroupRingSize, donut.GroupRingSpread) = (ringSize, spread); + var (face, rings) = donut.PlanLabels(bold, regular); + var where = $"{language} {profile.Id} {faceName} r{radius} hole {hole} ×{scale} {show} ring {ringSize}/{spread}" + + (direction is { } d ? $" {d}" : ""); + Check(layout, face, donut.Items, show, where, problems); + for (var g = 0; g < rings.Count; g++) + Check(layout, rings[g], donut.Items[g].Children ?? [], show, $"{where} ring {donut.Items[g].Id}", problems); + } + } + } + + private static void Check(SliceLabelLayout layout, IReadOnlyList ring, IReadOnlyList items, + LabelShow show, string where, List problems) + { + var autoKinds = new HashSet(); + var (allRoomForName, allRoomForBoth) = (true, true); + var (bothAsked, iconsAlone, allRoomForShortened) = (0, 0, true); + var ways = new HashSet { "straight", "out", "bent", "level" }; + for (var i = 0; i < ring.Count; i++) + { + var (label, item) = (ring[i], items[i]); + var name = item.Label; + var direction = item.Direction ?? LabelDirection.Auto; + var wanted = item.Show ?? show; + if (label.Kind == SliceLabelKind.None) + { + if (name.Length > 0 || item.IconKey is not null) + problems.Add($"{where}: \"{name}\" shows nothing"); + continue; + } + var zone = label.Zone; + var captionSize = SliceLabelLayout.CaptionSizeFor(label.NameSize); + + var line = Measure(layout, name, label.NameSize); + var icon = SliceLabelLayout.IconWithText * label.Scale; + var gap = SliceLabelLayout.IconGap * label.Scale; + var bends = layout.CanBend(name); + + if (label.Text is { Whole: false } cut) + { + // In the way it runs (a ring's names all run one way, unless a preset says its own). + var runs = direction != LabelDirection.Auto ? direction + : cut.Curved ? LabelDirection.AlongRing + : Math.Abs(Math.IEEERemainder(cut.Rotation - SliceLabelLayout.Along(zone.Mid), 2 * Math.PI)) < 1e-6 ? LabelDirection.OutFromCentre + : Math.Abs(Math.IEEERemainder(cut.Rotation, 2 * Math.PI)) < 1e-6 ? LabelDirection.Straight + : LabelDirection.AlongRing; + var room = label.Kind == SliceLabelKind.IconAndText + ? Room(zone, icon + gap + line.Width, Math.Max(icon, line.Height), runs, bends) + : Room(zone, line.Width, line.Height, runs, bends); + if (room is { } way) + problems.Add($"{where}: \"{name}\" cut to \"{cut.Lines[0]}\" though it has room whole ({way})"); + } + + if (wanted == LabelShow.IconAndText && item.IconKey is not null && name.Length > 0) + { + bothAsked++; + iconsAlone += label.Kind == SliceLabelKind.Icon ? 1 : 0; + var elements = new StringInfo(name.Trim()); + var kept = SliceLabelLayout.MinKept(name); + var shortest = kept >= elements.LengthInTextElements ? line + : Measure(layout, elements.SubstringByTextElements(0, kept).TrimEnd() + "…", label.NameSize); + allRoomForShortened &= Room(zone, icon + gap + shortest.Width, Math.Max(icon, shortest.Height), direction, bends) is not null; + } + + if (wanted == LabelShow.Auto && item.IconKey is not null && name.Length > 0) + { + autoKinds.Add(label.Kind); + allRoomForName &= Room(zone, line.Width, line.Height, direction, bends) is not null; + allRoomForBoth &= Room(zone, icon + gap + line.Width, Math.Max(icon, line.Height), direction, bends) is not null; + } + + if (label.Text is { } run && direction == LabelDirection.Auto) + { + bool Is(double turn) => Math.Abs(Math.IEEERemainder(run.Rotation - turn, 2 * Math.PI)) < 1e-6; + var could = run.Curved ? new HashSet { "bent" } : new HashSet(); + if (!run.Curved && Is(0)) could.Add("straight"); + if (!run.Curved && Is(SliceLabelLayout.Along(zone.Mid))) could.Add("out"); + if (!run.Curved && Is(SliceLabelLayout.Across(zone.Mid))) could.Add("level"); + ways.IntersectWith(could); + } + + if (label.Text is { Curved: true } && !layout.CanBend(name)) + problems.Add($"{where}: \"{name}\" is bent though its letters can't be"); + } + if (ways.Count == 0) + problems.Add($"{where}: names run in different directions"); + if (bothAsked > 0 && iconsAlone == bothAsked && allRoomForShortened) + problems.Add($"{where}: Icon & text shows icons alone though every slice has room for icon and name"); + else if (iconsAlone > 0 && iconsAlone < bothAsked) + problems.Add($"{where}: Icon & text mixes icons alone with icons and names"); + if (autoKinds.Count > 1) + problems.Add($"{where}: Auto slices mix {string.Join(", ", autoKinds)}"); + else if (autoKinds.SetEquals([SliceLabelKind.Icon]) && allRoomForName) + problems.Add($"{where}: Auto shows icons alone though every name has room"); + else if (autoKinds.SetEquals([SliceLabelKind.Text]) && allRoomForBoth) + problems.Add($"{where}: Auto shows names without icons though every slice has room for both"); + } + + // A name on one line as drawn. + private static Avalonia.Size Measure(SliceLabelLayout layout, string name, double size) + { + var text = new FormattedText(DonutMenu.KeepKoreanWords(name), CultureInfo.CurrentUICulture, layout.Fonts.Direction, + layout.Fonts.Bold, size, null); + return new Avalonia.Size(text.WidthIncludingTrailingWhitespace, text.Height); + } + + /// The share of a slice's room a label may take before it counts as plenty: the rest is kept to spare. + private const double Plenty = 0.85; + + /// + /// The way a block of by has plenty of room in + /// , among the directions allows, or null. Along the ring: its + /// height within the zone's depth, its width within the arc at its inner edge less the side margins. Out from the + /// centre: the same turned a quarter. Straight only on a slice at 12 or 6 o'clock, where it is the same as along. + /// + private static string? Room(SafeZone zone, double width, double height, LabelDirection direction, bool bends) + { + bool AlongRing() + { + if (height > zone.Depth * Plenty) + return false; + var inner = (zone.Inner + zone.Outer) / 2 - height / 2; + var arc = 2 * Math.Min(zone.HalfSpan, Math.PI) * inner - 2 * zone.Side; + return width <= arc * Plenty; + } + bool OutFromCentre() => width <= zone.Depth * Plenty && height <= (2 * Math.Min(zone.HalfSpan, Math.PI) * zone.Inner - 2 * zone.Side) * Plenty; + return direction switch + { + LabelDirection.AlongRing when bends => AlongRing() ? "along the ring" : null, + LabelDirection.OutFromCentre => OutFromCentre() ? "out from the centre" : null, + LabelDirection.Straight => null, + LabelDirection.Auto when bends && SliceLabelLayout.IsWide(zone) && AlongRing() => "along the ring", + LabelDirection.Auto when OutFromCentre() => "out from the centre", + _ => null, + }; + } +} diff --git a/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs b/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs index 21873f128..741d84b74 100644 --- a/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs +++ b/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs @@ -37,6 +37,27 @@ public void A_presets_icon_short_name_and_show_are_saved_and_copied() Assert.Equal(("crop", "Trim", LabelShow.IconOnly), (copy.Icon, copy.ShortName, copy.Show)); } + [Fact] + public void A_presets_text_direction_is_saved_and_copied() + { + var preset = new Preset { Id = "p", Direction = LabelDirection.AlongRing }; + Assert.Equal(LabelDirection.AlongRing, RoundTrip(preset).Direction); + Assert.Equal(LabelDirection.AlongRing, preset.Clone().Direction); + Assert.Null(RoundTrip(new Preset { Id = "q" }).Direction); + } + + [Theory] + [InlineData("\"Spiral\"")] // a newer Filee's choice + [InlineData("2")] + [InlineData("null")] + public void A_direction_this_version_doesnt_know_reads_as_unset(string value) + { + var json = $$"""[{"id":"p","name":"Mine","targetFormat":"png","direction":{{value}},"shortName":"M"}]"""; + var read = JsonSerializer.Deserialize(json, FileeJsonContext.Default.ListPreset)!.Single(); + Assert.Null(read.Direction); + Assert.Equal("M", read.ShortName); + } + [Fact] public void Unset_values_are_left_out_of_the_file_and_read_back_unset() { @@ -44,6 +65,7 @@ public void Unset_values_are_left_out_of_the_file_and_read_back_unset() Assert.DoesNotContain("\"icon\"", json); Assert.DoesNotContain("\"shortName\"", json); Assert.DoesNotContain("\"show\"", json); + Assert.DoesNotContain("\"direction\"", json); var read = JsonSerializer.Deserialize(json, FileeJsonContext.Default.ListPreset)!.Single(); Assert.Null(read.Icon); Assert.Null(read.ShortName); From ad090dd53366c9265fe77e173c34927fd010958d Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Tue, 10 Nov 2026 13:28:05 -0600 Subject: [PATCH 4/6] fix(donut): retune slice labels for all three from real-font renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The October rules were measured without the embedded fonts (git-ignored, missing from those trees), on the narrower system fallback. Rendered with Noto Sans on today's donuts (eight slices since #806) they made names smaller than today, dropped every caption on the PDF back face, showed no icons in Auto, and let #872 bend the names of every six-slice face. Retuned over about 1,000 renders in 10 languages, reviewed independently: - Names keep the ring's size and are shortened with "…" rather than made smaller; a ring steps down half a pixel at a time only where a name would be cut by a letter or two, or so far it no longer reads. A group's ring, of short words, still steps down first. - Each slice picks the best its Show allows at the ring's size and direction; Auto shows icons on all of a ring's names or on none. Captions show per slice where they fit, beside icons too; the two that tell slices apart (#869) stay with Show output format off, and slices of one name look alike. - A block that fits nowhere on a slice's middle line is tried round from it (a three-slice face's slices run aslant). - The face keeps its names upright unless a preset's Text direction says otherwise; Chinese, Japanese and Korean stay upright on a group's ring too; a script that can't bend is drawn level with the ring. - Icons sit above a bent name as read, one placement for a ring, beside it only on a ring's shallow chips. - Japanese and Chinese lines never end on an opening bracket or start on a closing one; a shortened name wraps like a whole one. - Icons for the built-ins added since October (Annotate, Add background, Metadata, Upscale, the N-up PDF and Crop video groups); the Actions branch's entries, which name no upstream preset, are gone. - Icon & text keeps its icons: a name shortened under its icon wraps as a name alone does ("Remove / metad…"), and where an icon and a name that still reads don't fit (the eight-slice back faces) the ring steps down rather than cut names to a word. - Spacing as today's: the margin, max(2 px, 2.5 % of the depth), is kept from the letters (their outlines, each line at its own width), not from the lines' boxes, which left 10-13 px round English names where today leaves 3-5. - A block goes on its slice's middle first, moved sideways to as much room left of it as right, and drops a caption it doesn't need there before it moves; lines wrap at today's width. A group slice keeps clear of its chevron, not of a band across the slice, so its label is centred as any other's; an icon alone steps round the chevron. - Auto shows icons only where they cost no slice its caption or its place on the middle. Tests: every rule above has one that fails with the rule taken out; SliceLabelFitTests and SliceLabelReadabilityTests sweep every built-in donut and ring, size, scale, Show mode, caption setting and language against them. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 92 +- src/Filee.App/Controls/DonutGeometry.cs | 6 +- src/Filee.App/Controls/DonutMenu.Groups.cs | 7 +- src/Filee.App/Controls/DonutMenu.cs | 49 +- src/Filee.App/Controls/SliceIcons.Data.cs | 16 +- src/Filee.App/Controls/SliceIcons.cs | 11 +- src/Filee.App/Controls/SliceLabelLayout.cs | 835 +++++++++++++----- .../ViewModels/IconPickerViewModel.cs | 10 +- .../ViewModels/Pages/ToolbarPageViewModel.cs | 8 +- src/Filee.App/ViewModels/RadialViewModel.cs | 8 +- src/Filee.App/ViewModels/SliceLabelFields.cs | 3 +- .../Views/SliceLabelFieldsView.axaml.cs | 4 +- src/Filee.Core/Localization/ILocalizer.cs | 3 +- src/Filee.Core/Presets/LabelShow.cs | 4 +- src/Filee.Core/Presets/Preset.cs | 12 +- src/Filee.Core/Profiles/ToolbarProfile.cs | 8 +- src/Filee.Core/Settings/AppSettings.cs | 16 +- tests/Filee.App.Tests/DonutGroupMarkTests.cs | 7 +- tests/Filee.App.Tests/RadialLabelTests.cs | 3 +- tests/Filee.App.Tests/SliceIconsTests.cs | 9 +- tests/Filee.App.Tests/SliceLabelFitTests.cs | 163 +++- .../Filee.App.Tests/SliceLabelLayoutTests.cs | 410 +++++++-- .../SliceLabelReadabilityTests.cs | 66 +- .../ToolbarLabelSettingsTests.cs | 7 +- 24 files changed, 1275 insertions(+), 482 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 04efd60e2..41f924dac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -297,44 +297,64 @@ pixels. At the defaults and radius 150 the ring is the same as before these sett ### Slice labels `Controls/SliceLabelLayout.cs` decides what each slice shows and where, without drawing; `DonutMenu.DrawSliceLabel` -draws the result. Every label stays inside the slice's *safe zone*: the slice inset by a margin of -`max(6·s, 0.08·depth)` from all four edges (plus half the 3 px gap on the sides, and clear of a group's chevron), -checked by sampling each box's outline, since the inner edge curves inward. `s = OuterRadius / 150` is the donut -scale; names start at `max(10, 13 × LabelScale × s)` px, captions 80 % of that, icons 20·k beside a name or 16–28·k -alone (k = LabelScale · s). +draws the result. Every label stays inside the slice's *safe zone*: the slice inset by a margin of `max(2·s, +0.025·depth)` from all four edges (plus half the 3 px gap on the sides), and as far from a group's chevron: the mark +itself, not a band across the slice, so a group slice's label is centred as any other's. The margin is kept from the +letters, measured from their outlines (`LabelText.InkBox`) with each line at its own width (`StackInside`), not from the +lines' boxes, which hold room above and below the letters: it leaves about the room today's donut leaves round its +names. A box's edges are checked against the inner edge too, since it curves inward. `s = OuterRadius / 150` is the +donut scale; names start at `max(10, 13 × LabelScale × s)` px, captions 80 % of that, icons 20·k beside a name or +16–28·k alone (k = LabelScale · s). What a slice shows is its preset's `Show` (`LabelShow`: Auto, IconAndText, TextOnly, IconOnly), else its donut's -`ToolbarProfile.LabelShow`, else Auto. `PlanRing` lays out a whole ring at once: one style for the slices that have -an icon, one name size and one icon size. Each style is tried at every size from the starting one down, 0.5 px at a -time, to `max(10, 11 × s)` (`SizesFor`; the icon beside a name shrinks with it, to 16·k), and taken only if every -slice fits. Auto: icon above the whole name, else the whole name alone, else icons. Icon & text: icon above the whole -name, else above a name shortened to no less than 60 % of its letters and never dropping a word (`MinKept`), else -icons alone; never a stub like "Tri…". Text only: whole names, else shortened to `MinKept`, else as far as it takes. -An icon stands in for the caption; names alone show their captions on every slice that has one or on none. A slice -without an icon (a format conversion) shows its name and doesn't decide the others' style. Icon and name: on a deep -slice the icon above the name, both upright, else (where that can't show the name whole, or for Out from the centre) -the icon before a name running out from the centre; on a wide slice the upright icon before a name bent along the -ring, side by side (a ring is too shallow for one above the other at most sizes), else above it. The icon never -turns. A ring takes one placement for all its icons and names, the one of its way (`ways` in `PlanRing`): straight -under an upright icon, out from the centre after the icon, or bent beside it; a format's name beside them runs that -way too, and a ring bends only if every name of it can (else all run level with it). Names wrap at word boundaries -(CJK by character, Korean by word) and run one way for the whole ring, each way through the sizes, except a slice -whose preset says its own (`Preset.Direction`: Straight, OutFromCentre, AlongRing; `SliceLabelLayout.Tries`): on a -slice deeper than it is wide (the donut's, mostly) straight or out from the centre, then bent; on one wider than deep -(a group ring's, or the donut's with a big hole; `IsWide`, so the ring's size, its spread and the name size decide) -bent along the ring, then straight, then out from the centre. A bent name (`LabelText.Curved`) puts each line on its -own circle and each letter on it, upright, clockwise on the top half and anticlockwise on the bottom -(`CurvedGlyphs`); it fits when its lines stay between the zone's circles and their ends clear the side edges at their -inner edge (`CurvedFits`). Scripts whose letters join or reorder (Arabic, Hebrew, the Indic scripts, Thai, Lao, -Myanmar) aren't bent but drawn level with the ring. A shortened name runs in the direction that keeps the most of it. -`DonutMenu.LabelPlan` caches each ring's plan by its inputs (items, geometry, scale, settings), so labels are laid -out once per change, not per frame; the open, pop and flip animations move them with transforms. Every group's ring -slices are equally wide, whatever the group's size (`DonutGeometry.SubSpan`: a share of the group's slice and a -quarter of each neighbour, as if three slices shared it, times Group ring spread), so a group of six fans out twice -as far as a group of three and the spread looks the same on every group; it never goes round more than once. A -group's ring labels follow the ring's scale instead of the donut's, and the Label scale as far as the ring's slices -have room: `PlanRingToFit` steps the Label scale down 5 % at a time, not below 100 %, until every slice shows its -whole name or its icon. +`ToolbarProfile.LabelShow`, else Auto. `PlanRing` lays out a whole ring at once: one name size, one way for its names +and one icon size, and for each slice the best its Show allows there. The ring's size runs from the starting one down, +0.5 px at a time, to `max(10, 10 × s)` (`SizesFor`; the icon beside a name shrinks with it, to 16·k), and the ring takes +the size and way with the fewest slices it can't label, then the fewest needed captions lost, then the fewest names cut +so far they no longer read (under four letters or 40 % of them) or by only a letter or two, then the earlier way, then +the largest size: on the face a name that doesn't fit is shortened rather than the ring made smaller (a group's ring, of +short words, steps down before it shortens), and optional captions never make the names smaller. Icon & text keeps its +icons: where an icon and its name, whole or shortened to `MinKept`, don't fit (the eight-slice back faces), the ring +steps down rather than cut the name to a word ("Merge…"), which says less. A block goes on the slice's middle first, its +letters centred there and moved sideways until it has as much room left of it as right of it (`Balanced`: a slice that +runs aslant has more on one side); there it drops a caption it doesn't need before it moves. Upright lines wrap at the +slice's width at its middle less 10 px (`ComfortableWidth`, as today's donut wraps its names), and run longer only where +no wrapping fits. Only a block that fits nowhere on the middle is tried along the middle line and round from it +(`Positions`): a three-slice face's slices run aslant. Auto: icon and whole name where every Auto slice of the ring has +room for both and loses nothing for it (the caption it shows without its icon, and its place on the middle), else the +whole name alone, else shortened to no less than 60 % of its letters and never dropping a word (`MinKept`), else +shorter, else its icon alone. Icon & text: icon and whole name, else (a slice that needs its caption) the name and +caption alone, else icon and name shortened to `MinKept` (wrapped as a name alone is: "Remove / metad…"), else the icon +alone. Text only: the whole name, else shortened to `MinKept`, else as far as it takes. Slices of one name look alike: +where one shows its name alone, the others do too. Captions show per slice, under a name where they fit, with Show +output format on. A caption the slice needs (`NeedsCaption`: "Copy to folder" or "Move to folder" under a folder's name, +or the format under a name another slice of the ring has too) shows with it off as well, and under the name beside an +icon; an icon otherwise stands in for the caption. A slice without an icon (a format conversion) shows its name and +doesn't decide the others' style. Icon and name: on a deep slice the icon above the name, both upright, else (where that +can't show the name whole, or for Out from the centre) the icon before a name running out from the centre; on a wide +slice the upright icon above a name bent along the ring, above it as read (the outer of the two on the top half, the +inner on the bottom), else (a group ring's chip, too shallow for one above the other at most sizes) before it, side by +side. The icon never turns. A ring takes one placement for all its icons (above their names where every one fits so, the +face stepping down for it; else before them) and names, the one of its way (`ways` in `PlanRing`): straight under an +upright icon, out from the centre after the icon, or bent beside it; a format's name beside them runs that way too, and +a ring bends only if every name of it can (else all run level with it). Names wrap at word boundaries (CJK by character, +Korean by word) and run one way for the whole ring, each way through the sizes, except a slice whose preset says its own +(`Preset.Direction`: Straight, OutFromCentre, AlongRing; `SliceLabelLayout.Tries`). The donut's face keeps its names +upright, and so does a group's ring of Chinese, Japanese or Korean names (turned, their characters read sideways). A +group's ring, deeper than it is wide, tries straight or out from the centre, then bent; one wider than deep (`IsWide`, +so the ring's size, its spread and the name size decide) bent along the ring, then straight, then out from the centre. A +bent name (`LabelText.Curved`) puts each line on its own circle and each letter on it, upright, clockwise on the top +half and anticlockwise on the bottom (`CurvedGlyphs`); it fits when its lines stay between the zone's circles and their +ends clear the side edges at their inner edge (`CurvedFits`). Scripts whose letters join or reorder (Arabic, Hebrew, the +Indic scripts, Thai, Lao, Myanmar) aren't bent but drawn level with the ring. A shortened name runs in the direction +that keeps the most of it. `DonutMenu.LabelPlan` caches each ring's plan by its inputs (items, geometry, scale, +settings), so labels are laid out once per change, not per frame; the open, pop and flip animations move them with +transforms. Every group's ring slices are equally wide, whatever the group's size (`DonutGeometry.SubSpan`: a share of +the group's slice and a quarter of each neighbour, as if three slices shared it, times Group ring spread), so a group of +six fans out twice as far as a group of three and the spread looks the same on every group; it never goes round more +than once. A group's ring labels follow the ring's scale instead of the donut's, and the Label scale as far as the +ring's slices have room: `PlanRingToFit` steps the Label scale down 5 % at a time, not below 100 %, until every slice +shows its whole name or its icon. The icons are a curated set of Material Design Icons (`SliceIcons.Data.cs`, generated; Apache-2.0, see THIRD-PARTY-NOTICES.md), keyed by their MDI names and grouped for the picker. `Preset.Icon` is a key, `"none"`, or diff --git a/src/Filee.App/Controls/DonutGeometry.cs b/src/Filee.App/Controls/DonutGeometry.cs index cd3b3c685..454dffb29 100644 --- a/src/Filee.App/Controls/DonutGeometry.cs +++ b/src/Filee.App/Controls/DonutGeometry.cs @@ -80,9 +80,9 @@ public static int IndexAt(double angle, int count) public const double MinSubWidth = 44; /// - /// How many slices share the fan of a group at its narrowest (): every ring slice is as wide as - /// one of this many, so a group of six fans out twice as far as a group of three, and Group ring spread looks the same - /// on every group. + /// How many slices share the fan of a group at its narrowest (): every ring slice is as wide + /// as one of this many, so a group of six fans out twice as far as a group of three, and Group ring spread looks + /// the same on every group. /// public const int SubReferenceCount = 3; diff --git a/src/Filee.App/Controls/DonutMenu.Groups.cs b/src/Filee.App/Controls/DonutMenu.Groups.cs index 859adcb51..c0ec793cd 100644 --- a/src/Filee.App/Controls/DonutMenu.Groups.cs +++ b/src/Filee.App/Controls/DonutMenu.Groups.cs @@ -28,7 +28,8 @@ public sealed partial class DonutMenu /// /// How large a group's ring is against the default: it follows the donut's size (radius ÷ 150) times the Group ring /// size setting, but is never smaller than the default ring on the smallest donut (): - /// smaller, a 10 px name (the floor) no longer fits its slices. Its depth, gap, narrowest slice and labels scale with it. + /// smaller, a 10 px name (the floor) no longer fits its slices. Its depth, gap, narrowest slice and labels scale + /// with it. /// public static double RingScaleFor(double outerRadius, double ringSize) => Math.Max(MinRingScale, outerRadius / SliceLabelLayout.DefaultRadius * ringSize); @@ -43,7 +44,9 @@ public static double RingScaleFor(double outerRadius, double ringSize) => public static double SubRingSpaceFor(double outerRadius, double ringSize) => (SubGap + SubBand + 10) * RingScaleFor(outerRadius, ringSize); - /// Width and height of a donut of , with room for group rings when asked. + /// + /// Width and height of a donut of , with room for group rings when asked. + /// public static double SideFor(double outerRadius, bool subRing, double ringSize = 1) => 2 * (outerRadius + PopOut + 14 + (subRing ? SubRingSpaceFor(outerRadius, ringSize) : 0)); diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index ad3d12760..3a95641d2 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -50,7 +50,10 @@ public sealed class DonutItem /// public IReadOnlyList? Children { get; init; } - /// The preset's full name, for the centre hint: the slice may show an icon or a shorter name. Null: . + /// + /// The preset's full name, for the centre hint: the slice may show an icon or a shorter name. Null: + /// . + /// public string? FullName { get; init; } /// The icon the slice can show (a key), or null for none. @@ -59,7 +62,9 @@ public sealed class DonutItem /// What the slice shows. Null: the donut's . public LabelShow? Show { get; init; } - /// Which way its name runs. Null: whichever reads best there (). + /// + /// Which way its name runs. Null: whichever reads best there (). + /// public LabelDirection? Direction { get; init; } /// A group slice ( set), even one left with nothing to fan out. @@ -215,12 +220,14 @@ public DonutMenu() public bool ShowCaptions { get => GetValue(ShowCaptionsProperty); set => SetValue(ShowCaptionsProperty, value); } /// - /// How large the slices' names and icons are (the donut's Label scale, 0.8 to 1.6); they also follow the donut's size - /// (). + /// How large the slices' names and icons are (the donut's Label scale, 0.8 to 1.6); they also follow the donut's + /// size (). /// public double LabelScale { get => GetValue(LabelScaleProperty); set => SetValue(LabelScaleProperty, value); } - /// How large a group's ring is, against its size on a donut of this radius (Group ring size, 1 = 100 %). + /// + /// How large a group's ring is, against its size on a donut of this radius (Group ring size, 1 = 100 %). + /// public double GroupRingSize { get => GetValue(GroupRingSizeProperty); set => SetValue(GroupRingSizeProperty, value); } /// How wide a group's ring spreads, against the default fan (Group ring spread, 1 = 100 %). @@ -1173,8 +1180,8 @@ private SliceLabelLayout LabelLayout(Typeface bold, Typeface regular) } /// - /// The labels of one ring of between and , - /// slice i centred on slice(i).Mid and slice(i).Span wide. + /// The labels of one ring of between and + /// , slice i centred on slice(i).Mid and slice(i).Span wide. /// private IReadOnlyList LabelPlan(IReadOnlyList items, Point center, double inner, double outer, Func slice, double scale, Typeface bold, Typeface regular, bool fitScale = false) @@ -1195,9 +1202,11 @@ private IReadOnlyList LabelPlan(IReadOnlyList items, Poin var inputs = items.Select((item, i) => { var (mid, span) = slice(i); + // The slices of this ring only: the outgoing face of a flip, or a group's ring, compares among its own. return new SliceLabelInput(item.Label, item.Caption, item.IconKey, item.Show ?? DefaultShow, - new SliceShape(center, inner, outer, mid, span, GapPixels, item.IsGroup ? ChevronKeepOut : 0), - item.Direction ?? LabelDirection.Auto); + new SliceShape(center, inner, outer, mid, span, GapPixels, item.IsGroup ? ChevronIn : 0, item.IsGroup ? ChevronReach : 0), + item.Direction ?? LabelDirection.Auto, + NeedsCaption: item.KeepsCaption || SharesItsName(item, items)); }).ToList(); PlansMade++; return _labelPlans[key] = fitScale @@ -1209,8 +1218,8 @@ private IReadOnlyList LabelPlan(IReadOnlyList items, Poin internal int PlansMade { get; private set; } /// - /// The labels of the face (at rest) and of every group's ring, laid out as they are drawn, in the given fonts (tests: - /// whether names fit, without drawing). + /// The labels of the face (at rest) and of every group's ring, laid out as they are drawn, in the given fonts + /// (tests: whether names fit, without drawing). /// internal (IReadOnlyList Face, IReadOnlyList> Rings) PlanLabels(Typeface bold, Typeface regular) { @@ -1233,7 +1242,9 @@ private IReadOnlyList LabelPlan(IReadOnlyList items, Poin return (face, rings); } - /// Draws a slice's laid-out icon and name in its text colour (on the accent when it's highlighted). + /// + /// Draws a slice's laid-out icon and name in its text colour (on the accent when it's highlighted). + /// private void DrawSliceLabel(DrawingContext context, SliceLabel label, bool enabled, bool hot) { var brush = hot ? OnAccentBrush : enabled ? Foreground : SecondaryForeground; @@ -1334,8 +1345,15 @@ private static StreamGeometry BuildGroupMark(Point c, double r2, double mid, dou // far to each side, and its line this thick. private const double GroupMarkInset = 7, GroupMarkDepth = 4.5, GroupMarkHalfWidth = 5, GroupMarkStroke = 1.6; - /// How far in from a group slice's outer edge its label keeps clear of the chevron. - private double ChevronKeepOut => (GroupMarkInset + GroupMarkDepth + 4) * DonutScale; + /// + /// A group slice's chevron, for its label to keep clear of: its middle this far in from the outer edge, and its + /// arms' ends and line this far round that. + /// + private double ChevronIn => (GroupMarkInset + GroupMarkDepth / 2) * DonutScale; + + /// + private double ChevronReach => + (Math.Sqrt(GroupMarkHalfWidth * GroupMarkHalfWidth + GroupMarkDepth * GroupMarkDepth / 4) + GroupMarkStroke / 2) * DonutScale; /// Chinese characters, kana and the CJK symbols and full-width forms: a line may break between any two. internal static bool IsHanOrKana(char c) => @@ -1435,7 +1453,8 @@ private void DrawPreview(DrawingContext context, Point center, double r1, double // One slice of its own: laid out each frame as it grows in, never cached. var layout = LabelLayout(bold, regular); var label = layout.PlanRing([new SliceLabelInput(_previewLabel, null, null, LabelShow.TextOnly, - new SliceShape(center, r1, r2, mid, span, GapPixels, _previewGroup ? ChevronKeepOut : 0))], LabelScale, DonutScale, + new SliceShape(center, r1, r2, mid, span, GapPixels, _previewGroup ? ChevronIn : 0, _previewGroup ? ChevronReach : 0))], + LabelScale, DonutScale, false)[0]; DrawSliceLabel(context, label, enabled: true, hot: false); if (_previewGroup) diff --git a/src/Filee.App/Controls/SliceIcons.Data.cs b/src/Filee.App/Controls/SliceIcons.Data.cs index 8bff3113f..b6ef67250 100644 --- a/src/Filee.App/Controls/SliceIcons.Data.cs +++ b/src/Filee.App/Controls/SliceIcons.Data.cs @@ -143,7 +143,9 @@ private static readonly (SliceIconCategory Category, string[] Keys)[] CategoryDa (SliceIconCategory.General, ["lightning-bolt-outline", "star-outline", "heart-outline", "bookmark-outline", "flag-outline", "check-circle-outline", "cog-outline", "magnify", "eye-outline", "lock-outline", "shield-outline", "clock-outline", "delete-outline", "sync", "map-marker-outline", "cube-outline", "drawing", "application-outline"]), ]; - /// The built-in presets' icons, by preset id. Plain format conversions have none: their name is the label. + /// + /// The built-in presets' icons, by preset id. Plain format conversions have none: their name is the label. + /// private static readonly Dictionary DefaultData = new() { ["half-size"] = "resize", @@ -194,11 +196,11 @@ private static readonly (SliceIconCategory Category, string[] Keys)[] CategoryDa ["caption-video"] = "microphone-outline", // AI Captions: speech to subtitles ["trace-svg"] = "vector-curve", ["trace-line-art"] = "draw", - ["action-share"] = "share-variant-outline", - ["action-copy-to"] = "content-copy", - ["action-move-to"] = "folder-move-outline", - ["action-copy-path"] = "link-variant", - ["action-terminal"] = "console", - ["actions"] = "lightning-bolt-outline", + ["tool-annotate"] = "brush-outline", + ["tool-background"] = "palette-outline", + ["tool-metadata"] = "tag-outline", + ["tool-upscale"] = "arrow-expand-all", + ["pdf-nup"] = "book-open-variant", + ["crop-video"] = "crop-landscape", }; } diff --git a/src/Filee.App/Controls/SliceIcons.cs b/src/Filee.App/Controls/SliceIcons.cs index e2b42cbc1..c435b43a0 100644 --- a/src/Filee.App/Controls/SliceIcons.cs +++ b/src/Filee.App/Controls/SliceIcons.cs @@ -53,9 +53,12 @@ internal static partial class SliceIcons internal static bool Exists(string? key) => key is not null && Index.ContainsKey(key); + /// The built-in presets that have an icon of their own (tests: each is one). + internal static IEnumerable DefaultIds => DefaultData.Keys; + /// - /// The icon shows when it has none of its own: a built-in preset's (by id), else its tool's, - /// else none. Plain format conversions have none: their name is their label. + /// The icon shows when it has none of its own: a built-in preset's (by id), else its + /// tool's, else none. Plain format conversions have none: their name is their label. /// internal static string? DefaultFor(Preset preset) => DefaultData.TryGetValue(preset.Id, out var key) ? key @@ -71,7 +74,9 @@ internal static partial class SliceIcons : Exists(preset.Icon) ? preset.Icon : DefaultFor(preset); - /// The shape of icon in a × square, or null. + /// + /// The shape of icon in a × square, or null. + /// internal static Geometry? Geometry(string? key) { if (key is null || !Index.TryGetValue(key, out var i)) diff --git a/src/Filee.App/Controls/SliceLabelLayout.cs b/src/Filee.App/Controls/SliceLabelLayout.cs index 61480e2f8..fc644fcb3 100644 --- a/src/Filee.App/Controls/SliceLabelLayout.cs +++ b/src/Filee.App/Controls/SliceLabelLayout.cs @@ -28,11 +28,13 @@ namespace Filee.App.Controls; /// -/// One slice's outline in the donut's coordinates (angles clockwise from 12 o'clock, as in ). -/// : how far in from the outer edge something else is drawn (a group's chevron), 0 for none. +/// One slice's outline in the donut's coordinates (angles clockwise from 12 o'clock, as in +/// ). A group's chevron, drawn on the slice's middle line by its outer edge: +/// in from the outer edge to its middle, and round that +/// (0 for none). /// internal readonly record struct SliceShape(Point Center, double Inner, double Outer, double Mid, double Span, double Gap, - double OuterKeepOut = 0); + double ChevronIn = 0, double ChevronReach = 0); /// A rectangle turned by (radians, clockwise) about its centre. internal readonly record struct LabelBox(Point Center, double Width, double Height, double Rotation) @@ -51,16 +53,24 @@ internal Point[] Corners() /// The part of a slice its label may use: between and from the /// donut's centre, and at least from both side edges. /// -internal readonly record struct SafeZone(Point Center, double Inner, double Outer, double Mid, double HalfSpan, double Side) +internal readonly record struct SafeZone(Point Center, double Inner, double Outer, double Mid, double HalfSpan, double Side, + Point KeepOut = default, double KeepOutRadius = 0) { - /// The inset: this share of the slice's depth, and at least px × the donut's scale. - internal const double MarginShare = 0.08, MinMargin = 6; + /// + /// The inset: this share of the slice's depth, and at least px × the donut's scale. It is + /// kept between the slice's edges and a label's letters (), not the boxes of its + /// lines, which hold room above and below them: about the room today's donut leaves round its names. + /// + internal const double MarginShare = 0.025, MinMargin = 2; internal static SafeZone Of(SliceShape shape, double scale) { var margin = Math.Max(MinMargin * scale, MarginShare * (shape.Outer - shape.Inner)); - return new SafeZone(shape.Center, shape.Inner + margin, shape.Outer - Math.Max(margin, shape.OuterKeepOut), - shape.Mid, shape.Span / 2, margin + shape.Gap / 2); + // A group's chevron keeps the label clear of itself, by the margin kept from the slice's edges, not of a band + // across the slice: the label stays centred on the slice and only steps round the mark. + var chevron = shape.ChevronReach > 0 ? DonutGeometry.PointAt(shape.Center, shape.Outer - shape.ChevronIn, shape.Mid) : default; + return new SafeZone(shape.Center, shape.Inner + margin, shape.Outer - margin, shape.Mid, shape.Span / 2, margin + shape.Gap / 2, + chevron, shape.ChevronReach > 0 ? shape.ChevronReach + margin : 0); } internal double Depth => Outer - Inner; @@ -70,9 +80,19 @@ internal static SafeZone Of(SliceShape shape, double scale) internal bool Contains(Point p) => Contains(p, 0); + /// + /// How far out from the centre a bent line may reach on the slice's middle line: to , or short + /// of the chevron there is one. + /// + internal double OuterOnMiddle => KeepOutRadius > 0 + ? Math.Min(Outer, Math.Sqrt(Math.Pow(KeepOut.X - Center.X, 2) + Math.Pow(KeepOut.Y - Center.Y, 2)) - KeepOutRadius) + : Outer; + /// True if a circle of round lies inside. internal bool Contains(Point p, double radius) { + if (KeepOutRadius > 0 && Math.Sqrt(Math.Pow(p.X - KeepOut.X, 2) + Math.Pow(p.Y - KeepOut.Y, 2)) < KeepOutRadius + radius - 1e-9) + return false; double dx = p.X - Center.X, dy = p.Y - Center.Y; var distance = Math.Sqrt(dx * dx + dy * dy); if (distance - radius < Inner - 1e-9 || distance + radius > Outer + 1e-9) @@ -93,12 +113,24 @@ internal bool Contains(LabelBox box) var corners = box.Corners(); if (!corners.All(Contains)) return false; + if (KeepOutRadius > 0 && DistanceToBox(KeepOut, box) < KeepOutRadius - 1e-9) + return false; for (var i = 0; i < corners.Length; i++) if (DistanceTo(corners[i], corners[(i + 1) % corners.Length]) < Inner - 1e-9) return false; return true; } + // The distance from p to the nearest point of a turned box (0 inside it). + private static double DistanceToBox(Point p, LabelBox box) + { + double c = Math.Cos(box.Rotation), sn = Math.Sin(box.Rotation); + double dx = p.X - box.Center.X, dy = p.Y - box.Center.Y; + double along = dx * c + dy * sn, across = -dx * sn + dy * c; // into the box's own axes + double ox = Math.Max(0, Math.Abs(along) - box.Width / 2), oy = Math.Max(0, Math.Abs(across) - box.Height / 2); + return Math.Sqrt(ox * ox + oy * oy); + } + // The distance from the donut's centre to the nearest point of the segment from a to b. private double DistanceTo(Point a, Point b) { @@ -125,7 +157,8 @@ internal sealed record LabelFonts(Typeface Bold, Typeface Regular, FlowDirection /// internal sealed record LabelText(IReadOnlyList Lines, IReadOnlyList LineHeights, double Size, string? Caption, double CaptionSize, double CaptionHeight, double Width, double Height, Point Anchor, double Rotation, - bool Whole, IReadOnlyList? CurvedWidths = null) + bool Whole, IReadOnlyList? CurvedWidths = null, double InkTop = 0, double InkBottom = 0, + IReadOnlyList? RowWidths = null) { /// /// Bent along the ring: each line on its own circle round the donut's centre, its letters placed one by one @@ -134,13 +167,72 @@ internal sealed record LabelText(IReadOnlyList Lines, IReadOnlyList internal bool Curved => CurvedWidths is not null; - /// Its outline, for straight text. + /// Its outline, for straight text: its lines' boxes, room above and below the letters included. internal LabelBox Box => new(Anchor, Width, Height, Rotation); - /// True when all of it lies inside . - internal bool Inside(SafeZone zone) => Curved ? SliceLabelLayout.CurvedFits(zone, this) : zone.Contains(Box); + /// + /// The outline of its letters, for straight text: less the room above its first line's letters + /// () and below its last line's (), as the font reports them. + /// + internal LabelBox InkBox => InkBoxAt(Anchor, Width, Height, Rotation, InkTop, InkBottom); + + /// The outline of the letters of a block laid out round . + internal static LabelBox InkBoxAt(Point anchor, double width, double height, double rotation, double inkTop, double inkBottom) + { + var shift = (inkTop - inkBottom) / 2; + return new LabelBox(new Point(anchor.X - shift * Math.Sin(rotation), anchor.Y + shift * Math.Cos(rotation)), width, + height - inkTop - inkBottom, rotation); + } + + /// + /// Where a block's lines go so that its letters' middle is : the inverse of + /// . + /// + internal static Point AnchorFor(Point letters, double rotation, double inkTop, double inkBottom) + { + var shift = (inkTop - inkBottom) / 2; + return new Point(letters.X + shift * Math.Sin(rotation), letters.Y - shift * Math.Cos(rotation)); + } + + /// + /// True when all of it lies inside : for straight text each line as wide as it is + /// (, its caption last), else the whole . + /// + internal bool Inside(SafeZone zone) => + Curved ? SliceLabelLayout.CurvedFits(zone, this) + : RowWidths is null ? zone.Contains(InkBox) + : StackInside(zone, InkBox.Center, Rotation, RowWidths, Caption is null ? LineHeights : [.. LineHeights, CaptionHeight], InkTop, InkBottom); + + /// + /// True when a stack of centred rows, its letters' middle at , lies inside + /// : each row as wide as its own text, the first less the room above its letters and the + /// last less the room below. A wedge narrows towards the hole, and a short line there needs less of it than the + /// widest line would. + /// + internal static bool StackInside(SafeZone zone, Point letters, double rotation, IReadOnlyList widths, + IReadOnlyList heights, double inkTop, double inkBottom) + { + double sin = Math.Sin(rotation), cos = Math.Cos(rotation); + var y = -(heights.Sum() - inkTop - inkBottom) / 2 - inkTop; // the first row's top, from the letters' middle + for (var i = 0; i < heights.Count; i++) + { + var top = y + (i == 0 ? inkTop : 0); + var bottom = y + heights[i] - (i == heights.Count - 1 ? inkBottom : 0); + var middle = (top + bottom) / 2; + var at = new Point(letters.X - middle * sin, letters.Y + middle * cos); + if (!zone.Contains(new LabelBox(at, widths[i], bottom - top, rotation))) + return false; + y += heights[i]; + } + return true; + } } +/// +/// Where an icon goes beside its name: above it as read, before it (side by side), or whichever fits. +/// +internal enum IconPlacement { Either, Above, Before } + /// What a slice shows: nothing, its name, its icon above its name, or its icon. internal enum SliceLabelKind { None, Text, IconAndText, Icon } @@ -162,27 +254,35 @@ internal sealed record SliceLabel(SliceLabelKind Kind, LabelText? Text, string? /// True when the slice's whole name is shown. internal bool NameWhole => Text?.Whole == true; - /// How far round an icon's centre it is checked against the zone: the drawn part of a 24 × 24 icon. + /// + /// How far round an icon's centre it is checked against the zone: the drawn part of a 24 × 24 icon. + /// internal static double IconReach(double size) => size * IconReachShare; internal const double IconReachShare = 0.6; } -/// One slice to lay out: its name, caption, icon and Show, and its outline. +/// +/// One slice to lay out: its name, caption, icon, Show and direction, and its outline. : +/// the caption tells the slice apart ("Copy to folder" or "Move to folder" under a folder's name, or the format under a +/// name another slice of the ring has too), so it shows with Show output format off, and beside an icon. +/// internal sealed record SliceLabelInput(string Name, string? Caption, string? IconKey, LabelShow Show, SliceShape Shape, - LabelDirection Direction = LabelDirection.Auto); + LabelDirection Direction = LabelDirection.Auto, bool NeedsCaption = false); /// Lays out the labels of one ring of slices (the donut's face, or a group's ring). internal sealed class SliceLabelLayout { - /// A name's size at Label scale 100 % on a donut of the default size, and the smallest it is drawn. + /// + /// A name's size at Label scale 100 % on a donut of the default size, and the smallest it is drawn. + /// internal const double NameSize = 13, MinNameSize = 10; /// /// How far a ring's names step down (× the donut's scale, in steps) for every name to fit /// whole: smaller gets hard to read. /// - internal const double MinStepNameSize = 11, SizeStep = 0.5; + internal const double MinStepNameSize = 10, SizeStep = 0.5; /// A caption's size as a share of the name's. internal const double CaptionShare = 0.8; @@ -196,7 +296,9 @@ internal sealed class SliceLabelLayout /// internal const double MinKeptShare = 0.6; - /// An icon above a name, and the largest and smallest icon drawn alone, at 100 % and the default size. + /// + /// An icon above a name, and the largest and smallest icon drawn alone, at 100 % and the default size. + /// internal const double IconWithText = 20, IconAloneMax = 28, IconAloneMin = 16, IconStep = 2, IconGap = 3; /// The default donut radius, at which the donut's scale is 1. @@ -205,8 +307,98 @@ internal sealed class SliceLabelLayout // Where along a slice's middle line a label is tried: the zone's middle first, then further in and out. private static readonly double[] Offsets = [0, 0.1, -0.1, 0.2, -0.2, 0.3, -0.3, 0.4, -0.4]; + /// + /// True when an upright label sits where a block is tried first: at its slice's middle height (with its icon, for + /// an icon above its name), as much room left of it as right of it at that height. Text turned or bent along its + /// slice, and an icon before its name, are placed along the middle line, so they count as there. + /// + internal static bool OnMiddle(SliceLabel label) + { + if (label.Text is not { Curved: false, Rotation: 0, RowWidths: not null } text) + return true; + var ink = text.InkBox; + var (top, bottom) = (ink.Center.Y - ink.Height / 2, ink.Center.Y + ink.Height / 2); + var icon = label.Kind == SliceLabelKind.IconAndText; + // An icon before its name (side by side, out from the centre) is placed along the middle line, as turned text. + if (icon && (Math.Abs(label.IconCenter.X - ink.Center.X) > 0.5 || label.IconCenter.Y > top)) + return true; + if (icon) + { + top = Math.Min(top, label.IconCenter.Y - label.IconSize / 2); + bottom = Math.Max(bottom, label.IconCenter.Y + label.IconSize / 2); + } + var middle = label.Zone.At((label.Zone.Inner + label.Zone.Outer) / 2); + if (Math.Abs((top + bottom) / 2 - middle.Y) >= 0.5) + return false; + var zone = label.Zone; + IReadOnlyList heights = text.Caption is null ? text.LineHeights : [.. text.LineHeights, text.CaptionHeight]; + bool Fits(double dx) => + LabelText.StackInside(zone, new Point(ink.Center.X + dx, ink.Center.Y), 0, text.RowWidths, heights, text.InkTop, text.InkBottom) && + (!icon || IconFits(zone, new Point(label.IconCenter.X + dx, label.IconCenter.Y), label.IconSize)); + return Math.Abs(Room(Fits, -1) - Room(Fits, 1)) <= 1; + } + + // An icon inside the zone: its drawn part, and its box clear of a chevron. + private static bool IconFits(SafeZone zone, Point icon, double size) => + zone.Contains(icon, SliceLabel.IconReach(size)) && zone.Contains(new LabelBox(icon, size, size, 0)); + + /// + /// How far a block can move along the screen's x, in (−1 left, +1 right), and still + /// fit: to a quarter pixel, up to 200 px. + /// + private static double Room(Func fits, int direction) + { + double fit = 0, step = 1; + while (fit + step <= 200 && fits(direction * (fit + step))) + { + fit += step; + step *= 2; + } + for (step /= 2; step >= 0.25; step /= 2) + if (fit + step <= 200 && fits(direction * (fit + step))) + fit += step; + return fit; + } + + /// + /// moved along the screen's x until a block there has as much room left of it as right of it: + /// a slice that runs aslant has more room on one side of its middle than the other at a given height. + /// + private static Point Balanced(Point at, Func fits) + { + var shift = (Room(fits, 1) - Room(fits, -1)) / 2; + return Math.Abs(shift) < 0.25 || !fits(shift) ? at : new Point(at.X + shift, at.Y); + } + + /// + /// How wide an upright line may be and still leave a slice room round it: the slice's width at its middle, less + /// 10 px, between 30 and 120 px, as today's donut wraps its names. A longer line goes only where no wrapping of + /// the name fits. + /// + internal static double ComfortableWidth(SafeZone zone) => + Math.Clamp(2 * zone.HalfSpan * (zone.Inner + zone.Outer) / 2 - 10, 30, 120); + + // How far round from the middle line an upright block is tried where it fits nowhere on it, as a share of the + // zone's half span, either way: a three-slice face's slices run aslant, and a line centred on their middle line + // meets the rim at one end and the hole at the other while the slice has room beside it. + private static readonly double[] Sideways = [0.15, -0.15, 0.3, -0.3, 0.45, -0.45]; + + /// + /// Where a block's middle is tried in : along its middle line first (), + /// then round from it () at each of those distances from the centre. + /// + private static IEnumerable Positions(SafeZone zone) + { + foreach (var offset in Offsets) + yield return zone.At((zone.Inner + zone.Outer) / 2 + offset * zone.Depth); + foreach (var across in Sideways) + foreach (var offset in Offsets) + yield return DonutGeometry.PointAt(zone.Center, (zone.Inner + zone.Outer) / 2 + offset * zone.Depth, + zone.Mid + across * zone.HalfSpan); + } + private readonly LabelFonts _fonts; - private readonly Dictionary<(string Text, Typeface Face, double Size), Size> _sizes = new(); + private readonly Dictionary<(string Text, Typeface Face, double Size), (Size Size, double InkTop, double InkBottom, double InkWidth)> _sizes = new(); private const int MaxCachedSizes = 2048; internal SliceLabelLayout(LabelFonts fonts) => _fonts = fonts; @@ -251,8 +443,8 @@ internal static int MinKept(string name) /// /// at or, if a slice wouldn't show its whole name or its icon - /// there, at the largest smaller Label scale (in steps, down to 100 %) at - /// which every slice does. For a group's ring, which has no room to grow (yet) with the labels. + /// there, at the largest smaller Label scale (in steps, down to 100 %) + /// at which every slice does. For a group's ring, which has no room to grow (yet) with the labels. /// internal IReadOnlyList PlanRingToFit(IReadOnlyList slices, double labelScale, double scale, bool showCaptions) @@ -260,7 +452,7 @@ internal IReadOnlyList PlanRingToFit(IReadOnlyList var floor = Math.Min(labelScale, 1); for (var step = labelScale; ; step = Math.Round(step - ToolbarProfile.LabelScaleStep, 2)) { - var plan = PlanRing(slices, Math.Max(step, floor), scale, showCaptions); + var plan = PlanRing(slices, Math.Max(step, floor), scale, showCaptions, ring: true); if (step <= floor + 1e-9 || plan.Select((label, i) => Fits(label, slices[i])).All(f => f)) return plan; } @@ -272,229 +464,301 @@ static bool Fits(SliceLabel label, SliceLabelInput slice) => } /// - /// The labels of one ring: one style for the slices of each Show that have an icon, one name size and one icon - /// size. Each style is tried at every size of , largest first, and taken only if every slice - /// fits: Auto (whole names only) icon and name, else name, else icon; Icon & text icon and name, whole names first, - /// then names shortened to , else the icon alone; Text only the name, whole first, then - /// shortened to , then as far as it takes (the centre names the slice in full). A slice - /// without an icon (a format) shows its name and never decides the others' style. Captions show under names alone - /// only, on every slice that has one or on none. + /// The labels of one ring: one name size, one direction for its names, one icon size, and for each slice the best + /// its Show allows there. The donut's face keeps its names upright; a group's ring () runs + /// them the way its shape reads best (); a preset's own direction keeps its slice. + /// Auto: icon above the whole name where every Auto slice of the ring has room for both and loses nothing for it + /// (the caption it shows without its icon, its place on the middle), else the whole name alone, else shortened, + /// else (last resort) the icon alone. Icon & text: icon above the whole name, else (a slice that needs its caption) + /// the name and caption alone, else icon above the name shortened to (on as many lines as it + /// takes), else the icon alone. Text only: the whole name, else shortened, else as far as it takes. Icon only: the + /// icon. A slice without an icon goes as Text only. The ring takes the size and direction with the fewest last + /// resorts, then the fewest needed captions lost, then the fewest names shortened past + /// (keeping the most letters), then the earlier direction, then the largest size, then the fewest names shortened + /// at all: on the face names keep their size and are shortened rather than made smaller (a group's ring, of short + /// words, steps down before it shortens), and captions never make them smaller. Icon & text keeps its icons: where + /// an icon and its name, whole or shortened to , don't fit, the ring steps down rather than + /// cut the name to a word ("Merge…"), which says less. Captions show per slice, under a name where they fit (with + /// Show output format on); a caption a slice needs () shows with it off + /// and beside an icon too. Slices of one name look alike. A block goes on its slice's middle first (moved sideways + /// to as much room left of it as right), where it drops a caption it doesn't need before it moves; upright lines + /// wrap at where that fits. /// internal IReadOnlyList PlanRing(IReadOnlyList slices, double labelScale, double scale, - bool showCaptions) + bool showCaptions, bool ring = false) { var baseSize = NameSizeFor(labelScale, scale); - var sizes = SizesFor(baseSize, scale); + // A group's ring grows its names with the ring, but steps them down to the donut's own floor before shortening. + var sizes = SizesFor(baseSize, ring ? Math.Min(scale, 1) : scale); var k = labelScale * scale; var zones = slices.Select(s => SafeZone.Of(s.Shape, scale)).ToArray(); - // The ways the ring's names are tried, one for all of them: on a deep ring straight, out from the centre, then - // bent; on a wide one bent, straight, then out from the centre. A preset's own direction keeps its slice. - LabelDirection[] ways = zones.Length > 0 && IsWide(zones[0]) - ? [LabelDirection.AlongRing, LabelDirection.Straight, LabelDirection.OutFromCentre] + var wide = zones.Length > 0 && IsWide(zones[0]); + // The directions the ring's names are tried in, one for all of them. The donut's face keeps them upright: + // turned out from the centre they read sideways on its top and bottom slices, and bent they lose their + // captions. A group's ring: deep, straight, out from the centre, then bent; wide, bent, straight, then out from + // the centre. + // Chinese, Japanese and Korean stay upright on a ring too: turned, their characters read sideways. + var upright = slices.Any(s => s.Direction == LabelDirection.Auto && s.Name.Any(c => DonutMenu.IsHanOrKana(c) || c is >= '\uAC00' and <= '\uD7A3')); + LabelDirection[] ways = !ring || upright ? [LabelDirection.Straight] + : wide ? [LabelDirection.AlongRing, LabelDirection.Straight, LabelDirection.OutFromCentre] : [LabelDirection.Straight, LabelDirection.OutFromCentre, LabelDirection.AlongRing]; - LabelDirection DirectionOf(int i, LabelDirection way) => slices[i].Direction == LabelDirection.Auto ? way : slices[i].Direction; // Bent along the ring only if every name of the ring can be (else all level with it). var ringBends = slices.Where(s => s.Direction == LabelDirection.Auto && s.Name.Length > 0).All(s => CanBend(s.Name)); bool? BendsOf(int i) => slices[i].Direction == LabelDirection.Auto ? ringBends : null; + LabelDirection DirectionOf(int i, LabelDirection way) => slices[i].Direction == LabelDirection.Auto ? way : slices[i].Direction; // An icon beside a smaller name shrinks with it, to no less than the smallest icon drawn alone. double IconFor(double size) => IconWithText * k * Math.Max(IconAloneMin / IconWithText, size / baseSize); - string? CaptionOf(SliceLabelInput s) => showCaptions && !string.IsNullOrEmpty(s.Caption) && + string? CaptionOf(SliceLabelInput s) => !string.IsNullOrEmpty(s.Caption) && !string.Equals(s.Caption, s.Name, StringComparison.OrdinalIgnoreCase) ? s.Caption : null; + bool Needs(SliceLabelInput s) => s.NeedsCaption && CaptionOf(s) is not null; - var result = new SliceLabel?[slices.Count]; - var wantIcon = new bool[slices.Count]; - - // The icon and the name, no caption: the icon stands in for it. - SliceLabel? Both(int i, double size, bool shorten, LabelDirection way) + // The icon beside the name, whole or shortened, with its caption where it fits (shown, or needed: a needed one + // has to). + SliceLabel? Both(int i, double size, bool shorten, LabelDirection way, IconPlacement placement) { var s = slices[i]; var icon = IconFor(size); - var fit = FitIconAndText(zones[i], s.Name, null, size, 0, icon, k, shorten, DirectionOf(i, way), MinKept(s.Name), BendsOf(i)); - return fit is { } f ? new SliceLabel(SliceLabelKind.IconAndText, f.Text, s.IconKey, f.Icon, icon, zones[i]) : null; + // Its caption under the name where it fits too ("1つに" says what it does only with "ZIP" under it). + var caption = Needs(s) || showCaptions ? CaptionOf(s) : null; + var fit = FitIconAndText(zones[i], s.Name, caption, size, CaptionSizeFor(size), icon, k, shorten, DirectionOf(i, way), + MinKept(s.Name), BendsOf(i), placement, captionNeeded: Needs(s)); + if (fit is not { } f || (Needs(s) && f.Text.Caption is null) || (shorten && f.Text.Whole)) + return null; + return new SliceLabel(SliceLabelKind.IconAndText, f.Text, s.IconKey, f.Icon, icon, zones[i]); } - // The name, with its caption when withCaption (null if the caption doesn't fit under it); shortened to minKept - // (a caption never goes with a shortened name). - SliceLabel? Name(int i, double size, bool withCaption, bool shorten, LabelDirection way, int? minKept = null) + // The whole name, with its caption where it fits (shown, or needed); with mustKeepNeeded, null if a needed one + // doesn't fit. + SliceLabel? Whole(int i, double size, LabelDirection way, bool mustKeepNeeded) { var s = slices[i]; - var caption = withCaption ? CaptionOf(s) : null; - var block = FitText(zones[i], s.Name, caption, size, CaptionSizeFor(size), alongToo: true, DirectionOf(i, way), BendsOf(i)); - if (caption is not null && block?.Caption is null) + var caption = showCaptions || Needs(s) ? CaptionOf(s) : null; + var block = FitText(zones[i], s.Name, caption, size, CaptionSizeFor(size), alongToo: true, DirectionOf(i, way), BendsOf(i), + captionNeeded: Needs(s)); + if (block is null || (mustKeepNeeded && Needs(s) && block.Caption is null)) return null; - if (block is null && shorten && caption is null) - block = FitShortened(zones[i], s.Name, size, alongToo: true, DirectionOf(i, way), minKept ?? MinKept(s.Name), BendsOf(i)); - return block is null ? null : new SliceLabel(SliceLabelKind.Text, block, null, default, 0, zones[i]); + return new SliceLabel(SliceLabelKind.Text, block, null, default, 0, zones[i]); } - // A style for all of group, at the largest size every slice fits; false (nothing set) if none does. A slice - // without an icon shows its name beside the others' icons, shortened if need be. - bool Take(List group, Func fit) + SliceLabel? Shortened(int i, double size, LabelDirection way, int minKept) => + FitShortened(zones[i], slices[i].Name, size, alongToo: true, DirectionOf(i, way), minKept, BendsOf(i)) is { } block + ? new SliceLabel(SliceLabelKind.Text, block, null, default, 0, zones[i]) + : null; + + // True when an icon would cost a slice what its name alone shows: a caption, or its place on the middle. + bool IconCosts(int i, SliceLabel both, double size, LabelDirection way) { - if (Plan(group, fit) is not { } plan) + if (Whole(i, size, way, mustKeepNeeded: false) is not { } alone) return false; - for (var j = 0; j < group.Count; j++) - result[group[j]] = plan[j]; - return true; + return (alone.Text!.Caption is not null && both.Text!.Caption is null) || (OnMiddle(alone) && !OnMiddle(both)); } - // The labels of group at the largest size every slice fits, or null. - List? Plan(List group, Func fit) + + // A name shortened by a letter or two ("الفيديوها…" for "الفيديوهات"): that reads as a mistake, so the ring + // steps down a little rather than cut so little. + bool Nearly(int i, SliceLabel label) => + TextElements(slices[i].Name.Trim()).Count(e => !string.IsNullOrWhiteSpace(e)) - + label.Text!.Lines.Sum(l => TextElements(l.TrimEnd('…')).Count(e => !string.IsNullOrWhiteSpace(e))) <= 2; + + // A name cut past MinKept that still reads (four letters and 40 % of them at least: "PDF zusam…") keeps the + // face's size, as any shortened name does; only a cut shorter than that makes the ring step down. A long word + // ("zusammenführen") would otherwise make every name of the face smaller. + bool Severe(int i, SliceLabel label) { - foreach (var size in sizes) - { - var plan = group.Select(i => fit(i, size)).ToList(); - if (plan.All(p => p is not null)) - return plan!; - } - return null; + var letters = TextElements(slices[i].Name.Trim()).Count(e => !string.IsNullOrWhiteSpace(e)); + var kept = label.Text!.Lines.Sum(l => TextElements(l.TrimEnd('…')).Count(e => !string.IsNullOrWhiteSpace(e))); + return ring || kept < 4 || kept < 0.4 * letters; } - // Icons and names, one way for the ring's names (a name without an icon runs that way too), each way through - // the sizes. - bool TakeBoth(List group, bool shorten) + + // What one slice shows at a size and direction: its label (null: its icon alone), and how far down its Show's + // list it had to go. + Pick PickFor(int i, double size, LabelDirection way, bool autoIcons, IconPlacement placement) { - if (!group.Any(i => slices[i].IconKey is not null)) - return false; - foreach (var way in ways) + var s = slices[i]; + if (s.Name.Length == 0) + return s.IconKey is null ? new Pick(SliceLabel.Empty) : new Pick(null); + var show = s.IconKey is null && s.Show != LabelShow.TextOnly ? LabelShow.TextOnly : s.Show; + switch (show) { - // A name without an icon runs the way the names beside icons do: out from the centre beside an icon - // before it, bent beside one on a wide ring that bends, else straight (under an upright icon). - var alone = way == LabelDirection.OutFromCentre ? way - : way == LabelDirection.AlongRing && zones.Length > 0 && IsWide(zones[0]) && ringBends ? way - : LabelDirection.Straight; - if (Take(group, (i, size) => slices[i].IconKey is null ? Name(i, size, false, true, alone) : Both(i, size, shorten, way))) - return true; + case LabelShow.IconOnly: + return new Pick(null); + case LabelShow.Auto: + if (autoIcons && Both(i, size, shorten: false, way, placement) is { } auto) + return new Pick(auto); + if (Whole(i, size, way, mustKeepNeeded: true) is { } alone) + return new Pick(alone); + if (Whole(i, size, way, mustKeepNeeded: false) is { } lost) + return new Pick(lost, NeededLost: true); + // Shortened rather than the icon alone among names: a lone icon there reads as a name gone missing. + if (Shortened(i, size, way, MinKept(s.Name)) is { } cut) + return new Pick(cut, Shortened: true, Nearly: Nearly(i, cut), NeededLost: Needs(s)); + if (Shortened(i, size, way, 1) is { } stub) + return new Pick(stub, Shortened: true, Stub: Severe(i, stub), NeededLost: Needs(s)); + return new Pick(null, LastResort: true, NeededLost: Needs(s)); + case LabelShow.IconAndText: + if (Both(i, size, shorten: false, way, placement) is { } both) + return new Pick(both); + if (Needs(s) && Whole(i, size, way, mustKeepNeeded: true) is { } told) + return new Pick(told, IconGivenUp: true); + if (Both(i, size, shorten: true, way, placement) is { } cutBoth) + return new Pick(cutBoth, Shortened: true, Nearly: Nearly(i, cutBoth), NeededLost: Needs(s)); + return new Pick(null, LastResort: true, NeededLost: Needs(s)); + default: + if (Whole(i, size, way, mustKeepNeeded: true) is { } name) + return new Pick(name); + if (Whole(i, size, way, mustKeepNeeded: false) is { } bare) + return new Pick(bare, NeededLost: true); + if (Shortened(i, size, way, MinKept(s.Name)) is { } shortened) + return new Pick(shortened, Shortened: true, Nearly: Nearly(i, shortened), NeededLost: Needs(s)); + // As far as it takes, a letter at least; else its icon, else nothing (the centre names it in full). + return Shortened(i, size, way, 1) is { } stubText + ? new Pick(stubText, Shortened: true, Stub: Severe(i, stubText), NeededLost: Needs(s)) + : new Pick(s.IconKey is null ? SliceLabel.Empty : null, LastResort: true, NeededLost: Needs(s)); } - return false; } - // One direction for the ring's names: straight through every size, then along the slices. - // Names alone, one way for the ring: captions on every slice that has one, else on none; of the ways, the one - // that shows the most names whole, then at the largest size, then keeps the most letters (the first way of - // those, in order, when they tie). - bool TakeNames(List group, bool shorten) + + Pick[]? best = null; + (int, int, int, int, int, int, int, double, int, int, int, int) bestKey = default; + (double Size, LabelDirection Way) at = default; + // The labels at a size, way and icon placement. + Pick[] PicksAt(double size, LabelDirection way, IconPlacement placement) { - foreach (var withCaption in group.Any(i => CaptionOf(slices[i]) is not null) ? new[] { true, false } : new[] { false }) - { - List? best = null; - (int Whole, double Size, int Letters) bestScore = default; - foreach (var way in ways) - { - if (Plan(group, (i, size) => Name(i, size, withCaption, shorten || slices[i].IconKey is null, way)) is not { } plan) - continue; - var score = (plan.Count(l => l.NameWhole), plan.Max(l => l.Text!.Size), plan.Sum(l => l.Text!.Lines.Sum(t => t.Length))); - if (best is null || score.CompareTo(bestScore) > 0) - (best, bestScore) = (plan, score); - } - if (best is not null) - { - for (var j = 0; j < group.Count; j++) - result[group[j]] = best[j]; - return true; - } - } - return false; + // Auto shows icons only where every Auto slice with an icon has room for its icon and name, and loses + // nothing for it: the caption it shows without its icon, and its place on the slice's middle. A ring never + // has some of its names with an icon and some without. + var autoIcons = Enumerable.Range(0, slices.Count).All(i => + slices[i].Show != LabelShow.Auto || slices[i].IconKey is null || slices[i].Name.Length == 0 || + Both(i, size, shorten: false, way, placement) is { } both && !IconCosts(i, both, size, way)); + return Enumerable.Range(0, slices.Count).Select(i => PickFor(i, size, way, autoIcons, placement)).ToArray(); } - // As far as it takes, at the smallest size, still one way for all of them. - void LastResort(List group) - { - foreach (var way in ways) + static int IconsOf(Pick[] picks) => picks.Count(p => p.Label?.Kind == SliceLabelKind.IconAndText); + static (int, int, int, int, int) Worse(Pick[] picks) => (picks.Count(p => p.LastResort), picks.Count(p => p.NeededLost), + picks.Count(p => p.Stub), picks.Count(p => p.Shortened), -IconsOf(picks)); + + for (var w = 0; w < ways.Length; w++) + foreach (var size in sizes) { - var plan = group.Select(i => Name(i, sizes[^1], false, true, way, 0)).ToList(); - if (plan.All(p => p is not null) || way == ways[^1]) - { - for (var j = 0; j < group.Count; j++) - result[group[j]] = plan[j] ?? SliceLabel.Empty; - return; - } + // One placement for every icon of the ring: above its name (as read, as in every other mode), else + // before it, side by side, where that labels the ring better (a group ring's shallow chips). + var above = PicksAt(size, ways[w], IconPlacement.Above); + var beside = PicksAt(size, ways[w], IconPlacement.Before); + var picks = Worse(beside).CompareTo(Worse(above)) < 0 ? beside : above; + var before = ReferenceEquals(picks, beside) && IconsOf(beside) > 0 ? 1 : 0; + var captionsLost = showCaptions + ? picks.Where((p, i) => p.Label?.Text is { Caption: null } && CaptionOf(slices[i]) is not null).Count() + : 0; + // Of names shortened past MinKept, the most letters kept. + var stubLetters = picks.Where(p => p.Stub).Sum(p => p.Label?.Text?.Lines.Sum(l => l.TrimEnd('…').Length) ?? 0); + // On the face, names keep their size and are shortened to MinKept rather than made smaller (smaller + // text is harder to read, and the centre names the slice in full); they step down only to keep more + // than MinKept. A group's ring holds short words ("Left", "16:9"), which a "…" ruins: it steps down + // first. The face steps down too to put its icons above their names rather than before them; a group's + // ring, its chips too shallow for that at most sizes, keeps its size. + var shortened = picks.Count(p => p.Shortened); + var key = (picks.Count(p => p.LastResort), picks.Count(p => p.NeededLost), picks.Count(p => p.Stub || p.Nearly), -stubLetters, + ring ? shortened : 0, ring ? 0 : before, w, -size, ring ? 0 : shortened, picks.Count(p => p.IconGivenUp), captionsLost, + ring ? before : 0); + if (best is null || key.CompareTo(bestKey) < 0) + (best, bestKey, at) = (picks, key, (size, ways[w])); } - } - List Showing(LabelShow show) => Enumerable.Range(0, slices.Count) - .Where(i => slices[i].Show == show && slices[i].Name.Length > 0).ToList(); - void Icons(List group) - { - foreach (var i in group) - wantIcon[i] = slices[i].IconKey is not null; - } - var auto = Showing(LabelShow.Auto); - if (auto.Count > 0 && !TakeBoth(auto, false) && !TakeNames(auto, false)) - Icons(auto); - var both = Showing(LabelShow.IconAndText); - if (both.Count > 0 && !TakeBoth(both, false) && !TakeBoth(both, true)) - Icons(both); - var names = Showing(LabelShow.TextOnly); - if (names.Count > 0 && !TakeNames(names, false) && !TakeNames(names, true)) - LastResort(names); // the centre names the slice in full - - // What's left: icons (Icon only, or a ring gone to icons) and names of slices without one. - var rest = new List(); - for (var i = 0; i < slices.Count; i++) + var result = (best ?? []).Select(p => p.Label).ToArray(); + // Slices of one name look alike: where one of them shows its name alone, the others do too ("Rotate left" as + // Same format and as PDF, or a Copy and a Move slice for one folder). + foreach (var same in Enumerable.Range(0, slices.Count) + .Where(i => slices[i].Name.Length > 0 && slices[i].IconKey is not null) + .GroupBy(i => (slices[i].Name, slices[i].Show), NameComparer).Where(g => g.Count() > 1)) + if (same.Any(i => result[i]?.Kind == SliceLabelKind.Text)) + foreach (var i in same.Where(i => result[i]?.Kind == SliceLabelKind.IconAndText)) + result[i] = Whole(i, at.Size, at.Way, mustKeepNeeded: true) ?? Whole(i, at.Size, at.Way, mustKeepNeeded: false) ?? result[i]; + + // Icons alone: one size for the ring, the largest every one of them fits at, on its slice's middle line: in the + // middle, or (a group's, clear of its chevron) as near it as it fits, half a pixel at a time. + var alone = Enumerable.Range(0, slices.Count).Where(i => result[i] is null && slices[i].IconKey is not null).ToList(); + Point? IconPlace(int i, double icon) { - var s = slices[i]; - if (result[i] is not null || wantIcon[i]) - continue; - if (s.Name.Length == 0 && s.IconKey is null) - result[i] = SliceLabel.Empty; - else if (s.IconKey is not null && (s.Show == LabelShow.IconOnly || s.Name.Length == 0)) - wantIcon[i] = true; - else - rest.Add(i); + var zone = zones[i]; + var middle = (zone.Inner + zone.Outer) / 2; + for (var d = 0.0; d <= zone.Depth / 2; d += 0.5) + foreach (var r in d == 0 ? [middle] : new[] { middle - d, middle + d }) + if (zone.Contains(zone.At(r), SliceLabel.IconReach(icon))) + return zone.At(r); + return null; } - if (rest.Count > 0 && !TakeNames(rest, false) && !TakeNames(rest, true)) - LastResort(rest); - - // Icons alone: one size for the ring, the largest every one of them fits at, centred in its slice. - var alone = Enumerable.Range(0, slices.Count).Where(i => wantIcon[i]).ToList(); if (alone.Count > 0) { - var size = 0.0; - for (var candidate = IconAloneMax; candidate >= IconAloneMin - 1e-9; candidate -= IconStep) - if (alone.All(i => zones[i].Contains(zones[i].At((zones[i].Inner + zones[i].Outer) / 2), SliceLabel.IconReach(candidate * k)))) - { - size = candidate * k; - break; - } - // Next to icons drawn beside names, the same size as those: one icon size for the ring. + bool FitsAll(double icon) => alone.All(i => IconPlace(i, icon) is not null); + // Next to icons drawn beside names, the same size as those where they fit: one icon size for the ring. var withName = result.Where(r => r?.Kind == SliceLabelKind.IconAndText).Select(r => r!.IconSize).DefaultIfEmpty(0).Max(); - if (size > 0 && withName > 0) - size = Math.Min(size, withName); - if (size > 0) - foreach (var i in alone) - result[i] = new SliceLabel(SliceLabelKind.Icon, null, slices[i].IconKey, zones[i].At((zones[i].Inner + zones[i].Outer) / 2), size, zones[i]); - else - LastResort(alone); + var size = withName > 0 && FitsAll(withName) ? withName : 0.0; + for (var candidate = IconAloneMax; size == 0 && candidate >= IconAloneMin - 1e-9; candidate -= IconStep) + if ((withName == 0 || candidate * k <= withName) && FitsAll(candidate * k)) + size = candidate * k; + foreach (var i in alone) + result[i] = size > 0 + ? new SliceLabel(SliceLabelKind.Icon, null, slices[i].IconKey, IconPlace(i, size)!.Value, size, zones[i]) + : Shortened(i, sizes[^1], ways[0], 0) ?? SliceLabel.Empty; } - // The size each label's name was laid out at (for icons alone, the smallest tried), and the scale of its icon. + // The size each label's name was laid out at (for icons alone, the ring's), and the scale of its icon. return result.Select((r, i) => { - var size = r?.Text?.Size ?? sizes[^1]; + var size = r?.Text?.Size ?? at.Size; + if (size <= 0) + size = sizes[^1]; return (r ?? SliceLabel.Empty with { Zone = zones[i] }) with { NameSize = size, Scale = IconFor(size) / IconWithText }; }).ToArray(); } + // One slice's choice at a size and direction: its label (null: its icon alone, sized with the ring's). + private readonly record struct Pick(SliceLabel? Label, bool LastResort = false, bool NeededLost = false, bool Nearly = false, + bool Shortened = false, bool Stub = false, bool IconGivenUp = false); + + private static readonly IEqualityComparer<(string Name, LabelShow Show)> NameComparer = + EqualityComparer<(string, LabelShow)>.Create( + (a, b) => a.Item2 == b.Item2 && string.Equals(a.Item1, b.Item1, StringComparison.OrdinalIgnoreCase), + k => HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(k.Item1), k.Item2)); + /// /// The name whole (with its caption under it when both fit), in as few lines as it takes, in the directions /// allows (; only straight without ). - /// Null if it doesn't fit whole. + /// On the slice's middle first: there the name drops a caption it doesn't need before it moves off the middle + /// (: one that tells the slice apart moves with it instead). Null if it doesn't + /// fit whole. /// internal LabelText? FitText(SafeZone zone, string name, string? caption, double size, double captionSize, bool alongToo, - LabelDirection direction = LabelDirection.Auto, bool? bends = null) + LabelDirection direction = LabelDirection.Auto, bool? bends = null, bool captionNeeded = false) { var units = Units(name); if (units.Count == 0) return null; + var comfortable = ComfortableWidth(zone); foreach (var turns in Tries(zone, name, alongToo, direction, bends)) - foreach (var withCaption in caption is null ? new[] { false } : new[] { true, false }) - for (var lines = 1; lines <= Math.Min(MaxLines, units.Count); lines++) + foreach (var middle in new[] { true, false }) + foreach (var withCaption in caption is null ? new[] { false } : new[] { true, false }) { - var text = Split(units, lines, size); - foreach (var turn in turns) - if (Place(zone, text, size, withCaption ? caption : null, captionSize, turn, whole: true) is { } block) - return block; + if (middle && !withCaption && captionNeeded && caption is not null) + continue; + // Upright, its lines no wider than the slice leaves them room for comfortably, wrapped as today's + // donut wraps them, where that fits; else as few lines as fit. + foreach (var comfort in new[] { true, false }) + for (var lines = 1; lines <= Math.Min(MaxLines, units.Count); lines++) + { + var text = Split(units, lines, size); + var wide = text.Any(l => SizeOf(l, _fonts.Bold, size).Width > comfortable); + foreach (var turn in turns) + { + // Upright: the comfortable wrappings first, then the wide ones. Turned: once. + if (turn == 0 ? comfort == wide : !comfort) + continue; + if (Place(zone, text, size, withCaption ? caption : null, captionSize, turn, whole: true, middle) is { } block) + return block; + } + } } return null; } /// - /// The name on one line, shortened with "…" until it fits, keeping text elements at least, - /// in whichever direction keeps the most of it. Null if it doesn't fit even then. + /// The name shortened with "…" until it fits, keeping text elements at least, on as few + /// lines as it takes (up to , wrapped at word breaks as a whole name is: "Remove / + /// backgro…"), in whichever direction keeps the most of it. Null if it doesn't fit even then. /// internal LabelText? FitShortened(SafeZone zone, string name, double size, bool alongToo, LabelDirection direction = LabelDirection.Auto, int minKept = 0, bool? bends = null) @@ -503,22 +767,26 @@ void Icons(List group) var elements = TextElements(name.Trim()); for (var keep = elements.Count - 1; keep >= minKept; keep--) { - var text = string.Concat(elements.Take(keep)).TrimEnd() + "…"; - foreach (var turn in turns) - if (Place(zone, [text], size, null, 0, turn, whole: false) is { } block) - return block; + var units = Units(string.Concat(elements.Take(keep)).TrimEnd() + "…"); + for (var lines = 1; lines <= Math.Min(MaxLines, units.Count); lines++) + { + var text = Split(units, lines, size); + foreach (var turn in turns) + if (Place(zone, text, size, null, 0, turn, whole: false) is { } block) + return block; + } } return null; } /// - /// The icon above the name, both upright, placed along the slice's middle line; the name shortened to one line - /// (keeping text elements at least) if and it doesn't fit - /// whole. Null if they don't fit. + /// The icon above the name, both upright, placed along the slice's middle line; the name shortened (keeping + /// text elements at least, on as few lines as it takes) if + /// and it doesn't fit whole. Null if they don't fit. /// internal (LabelText Text, Point Icon)? FitIconAndText(SafeZone zone, string name, string? caption, double size, double captionSize, double iconSize, double scale, bool shorten, LabelDirection direction = LabelDirection.Auto, - int minKept = 1, bool? bends = null) + int minKept = 1, bool? bends = null, IconPlacement placement = IconPlacement.Either, bool captionNeeded = false) { var units = Units(name); if (units.Count == 0) @@ -528,62 +796,87 @@ void Icons(List group) foreach (var withCaption in caption is null ? new[] { false } : new[] { true, false }) for (var lines = 1; lines <= Math.Min(MaxLines, units.Count); lines++) options.Add((Split(units, lines, size), withCaption ? caption : null, true)); + // Shortened, on as few lines as it takes, as a name alone is ("Remove / metad…" rather than "Remov…"). if (shorten) { var elements = TextElements(name.Trim()); for (var keep = elements.Count - 1; keep >= Math.Max(1, minKept); keep--) - options.Add(([string.Concat(elements.Take(keep)).TrimEnd() + "…"], null, false)); + { + var cut = Units(string.Concat(elements.Take(keep)).TrimEnd() + "…"); + for (var lines = 1; lines <= Math.Min(MaxLines, cut.Count); lines++) + options.Add((Split(cut, lines, size), null, false)); + } } // On a wide slice an upright stack only fits where the ring runs up and down: the name bends along the ring - // beside (or under) the upright icon instead, on every slice of the ring alike. + // under the upright icon instead (above it as read, as in every other mode), or beside it where the slice is + // too shallow for one above the other (a group ring's chip). var bent = direction is LabelDirection.Auto or LabelDirection.AlongRing && IsWide(zone) && (bends ?? CanBend(name)); if (bent) { foreach (var (lines, cap, whole) in options) { var (_, heights, _) = Measure(lines, size, cap, captionSize); - if (cap is null && (FitIconBesideBent(zone, lines, heights, size, captionSize, iconSize, gap, whole) - ?? FitIconOverBent(zone, lines, heights, size, captionSize, iconSize, gap, whole)) is { } fit) + if (cap is null && ((placement != IconPlacement.Before ? FitIconOverBent(zone, lines, heights, size, captionSize, iconSize, gap, whole) : null) + ?? (placement != IconPlacement.Above ? FitIconBesideBent(zone, lines, heights, size, captionSize, iconSize, gap, whole) : null)) is { } fit) return fit; } return null; } // A deep slice: the icon above its name, both upright; where that can't show the name whole, the icon before a // name running out from the centre (the only way for Out from the centre). Whole names before shortened ones. - var above = direction != LabelDirection.OutFromCentre; - var before = direction is LabelDirection.Auto or LabelDirection.OutFromCentre; + var above = direction != LabelDirection.OutFromCentre && placement != IconPlacement.Before; + var before = direction is LabelDirection.Auto or LabelDirection.OutFromCentre && placement != IconPlacement.Above; + // On the slice's middle first, where a caption it doesn't need goes before the block moves off the middle. foreach (var whole in new[] { true, false }) - foreach (var beside in new[] { false, true }) - { - if (beside ? !before : !above) - continue; - foreach (var (lines, cap, isWhole) in options.Where(o => o.Whole == whole)) + foreach (var middle in new[] { true, false }) + foreach (var beside in new[] { false, true }) { - var (textWidth, heights, captionHeight) = Measure(lines, size, cap, captionSize); - if ((beside - ? FitIconBeforeAlong(zone, lines, heights, textWidth, cap, captionSize, captionHeight, size, iconSize, gap, isWhole) - : FitIconAbove(zone, lines, heights, textWidth, cap, captionSize, captionHeight, size, iconSize, gap, isWhole)) - is { } fit) - return fit; + if (beside ? !before : !above) + continue; + // Upright under its icon, the name wrapped comfortably first (ComfortableWidth). + var comfortable = ComfortableWidth(zone); + foreach (var (lines, cap, isWhole) in options.Where(o => o.Whole == whole) + .OrderBy(o => !beside && o.Lines.Any(l => SizeOf(l, _fonts.Bold, size).Width > comfortable))) + { + if (middle && cap is null && captionNeeded && caption is not null) + continue; + var (textWidth, heights, captionHeight) = Measure(lines, size, cap, captionSize); + var (inkTop, inkBottom) = Ink(lines, size, cap, captionSize); + var rows = RowWidthsOf(lines, size, cap, captionSize); + if ((beside + ? FitIconBeforeAlong(zone, lines, heights, textWidth, cap, captionSize, captionHeight, size, iconSize, gap, isWhole, inkTop, inkBottom, rows, middle) + : FitIconAbove(zone, lines, heights, textWidth, cap, captionSize, captionHeight, size, iconSize, gap, isWhole, inkTop, inkBottom, rows, middle)) + is { } fit) + return fit; + } } - } return null; } - // The icon above its name, both upright, on the slice's middle line. + // The icon above its name, both upright, on the slice's middle line: the block from the icon's top to the last + // line's letters centred there. private static (LabelText Text, Point Icon)? FitIconAbove(SafeZone zone, IReadOnlyList lines, double[] heights, - double textWidth, string? cap, double captionSize, double captionHeight, double size, double iconSize, double gap, bool whole) + double textWidth, string? cap, double captionSize, double captionHeight, double size, double iconSize, double gap, bool whole, + double inkTop, double inkBottom, IReadOnlyList rows, bool middleOnly) { var textHeight = heights.Sum() + captionHeight; - var width = Math.Max(iconSize, textWidth); - var height = iconSize + gap + textHeight; - foreach (var offset in Offsets) + var height = iconSize + gap + textHeight - inkBottom; + IReadOnlyList rowHeights = cap is null ? heights : [.. heights, captionHeight]; + foreach (var at in middleOnly ? Positions(zone).Take(1) : Positions(zone)) { - var at = zone.At((zone.Inner + zone.Outer) / 2 + offset * zone.Depth); var icon = new Point(at.X, at.Y - height / 2 + iconSize / 2); - var textAt = new Point(at.X, at.Y + height / 2 - textHeight / 2); - if (zone.Contains(new LabelBox(at, width, height, 0)) && zone.Contains(icon, SliceLabel.IconReach(iconSize))) - return (new LabelText(lines, heights, size, cap, captionSize, captionHeight, textWidth, textHeight, textAt, 0, whole), icon); + var textAt = new Point(at.X, at.Y - height / 2 + iconSize + gap + textHeight / 2); + var letters = LabelText.InkBoxAt(textAt, textWidth, textHeight, 0, inkTop, inkBottom).Center; + bool Fits(double dx) => + IconFits(zone, new Point(icon.X + dx, icon.Y), iconSize) && + LabelText.StackInside(zone, new Point(letters.X + dx, letters.Y), 0, rows, rowHeights, inkTop, inkBottom); + if (!Fits(0)) + continue; + // Moved sideways to have as much room left of it as right of it. + var shift = Balanced(default, Fits).X; + return (new LabelText(lines, heights, size, cap, captionSize, captionHeight, textWidth, textHeight, + new Point(textAt.X + shift, textAt.Y), 0, whole, InkTop: inkTop, InkBottom: inkBottom, RowWidths: rows), + new Point(icon.X + shift, icon.Y)); } return null; } @@ -593,28 +886,33 @@ private static (LabelText Text, Point Icon)? FitIconAbove(SafeZone zone, IReadOn /// the inner of the two on the right half (read outward) and the outer on the left half (read inward). /// private static (LabelText Text, Point Icon)? FitIconBeforeAlong(SafeZone zone, IReadOnlyList lines, double[] heights, - double textWidth, string? cap, double captionSize, double captionHeight, double size, double iconSize, double gap, bool whole) + double textWidth, string? cap, double captionSize, double captionHeight, double size, double iconSize, double gap, bool whole, + double inkTop, double inkBottom, IReadOnlyList rows, bool middleOnly) { var textHeight = heights.Sum() + captionHeight; + IReadOnlyList rowHeights = cap is null ? heights : [.. heights, captionHeight]; var turn = Along(zone.Mid); var a = (zone.Mid % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI); var outward = a < Math.PI ? 1 : -1; // which way the name reads along the middle line var total = iconSize + gap + textWidth; - foreach (var offset in Offsets) + foreach (var offset in middleOnly ? Offsets.Take(1) : Offsets) { var middle = (zone.Inner + zone.Outer) / 2 + offset * zone.Depth; var icon = zone.At(middle - outward * (total / 2 - iconSize / 2)); - var textAt = zone.At(middle + outward * (total / 2 - textWidth / 2)); - if (zone.Contains(icon, SliceLabel.IconReach(iconSize)) && zone.Contains(new LabelBox(textAt, textWidth, textHeight, turn))) - return (new LabelText(lines, heights, size, cap, captionSize, captionHeight, textWidth, textHeight, textAt, turn, whole), icon); + var letters = zone.At(middle + outward * (total / 2 - textWidth / 2)); + if (zone.Contains(icon, SliceLabel.IconReach(iconSize)) && + LabelText.StackInside(zone, letters, turn, rows, rowHeights, inkTop, inkBottom)) + return (new LabelText(lines, heights, size, cap, captionSize, captionHeight, textWidth, textHeight, + LabelText.AnchorFor(letters, turn, inkTop, inkBottom), turn, whole, InkTop: inkTop, InkBottom: inkBottom, + RowWidths: rows), icon); } return null; } /// /// The upright icon before a name bent along the ring, side by side along it (first as read: clockwise on the top - /// half, anticlockwise on the bottom): a group ring is wide and shallow, too shallow at most sizes for one above the - /// other. Null if they don't fit. + /// half, anticlockwise on the bottom), where the slice is too shallow for one above the other (a group ring's chip, + /// at most sizes). Null if they don't fit. /// private (LabelText Text, Point Icon)? FitIconBesideBent(SafeZone zone, IReadOnlyList lines, double[] heights, double size, double captionSize, double iconSize, double gap, bool whole) @@ -663,8 +961,8 @@ private static (LabelText Text, Point Icon)? FitIconBeforeAlong(SafeZone zone, I // The turns a name is tried in: straight, along the slice, or straight then along. /// - /// The turn that runs text along a slice whose middle is at : outward on the right half, inward - /// on the left half, so it is never upside down. + /// The turn that runs text along a slice whose middle is at : outward on the right half, + /// inward on the left half, so it is never upside down. /// internal static double Along(double mid) { @@ -731,7 +1029,9 @@ private static double LineRadius(SafeZone zone, double middle, IReadOnlyListTrue on the bottom half of the donut, where bent text runs the other way round to stay upright. + /// + /// True on the bottom half of the donut, where bent text runs the other way round to stay upright. + /// internal static bool Bottom(double mid) { var a = (mid % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI); @@ -745,14 +1045,16 @@ internal static bool Bottom(double mid) internal static bool CurvedFits(SafeZone zone, LabelText text) => CurvedFits(zone, Distance(zone.Center, text.Anchor), text.LineHeights, text.Height, text.CurvedWidths!, Shift(zone, text)); - /// How far round from the zone's middle a bent name is centred (beside an icon): its anchor's angle. + /// + /// How far round from the zone's middle a bent name is centred (beside an icon): its anchor's angle. + /// private static double Shift(SafeZone zone, LabelText text) => Math.IEEERemainder(DonutGeometry.AngleOf(zone.Center, text.Anchor) - zone.Mid, 2 * Math.PI); private static bool CurvedFits(SafeZone zone, double middle, IReadOnlyList heights, double height, IReadOnlyList widths, double shift = 0) { - if (middle - height / 2 < zone.Inner - 1e-9 || middle + height / 2 > zone.Outer + 1e-9) + if (middle - height / 2 < zone.Inner - 1e-9 || middle + height / 2 > zone.OuterOnMiddle + 1e-9) return false; for (var i = 0; i < heights.Count; i++) { @@ -813,7 +1115,7 @@ private double AdvanceOf(string element, double size) private double CurvedWidth(string line, double size) => TextElements(line).Sum(e => AdvanceOf(e, size)); private LabelText? Place(SafeZone zone, IReadOnlyList lines, double size, string? caption, double captionSize, - double turn, bool whole) + double turn, bool whole, bool middleOnly = false) { var (width, heights, captionHeight) = Measure(lines, size, caption, captionSize); var height = heights.Sum() + captionHeight; @@ -831,11 +1133,20 @@ private double AdvanceOf(string element, double size) } return null; } - foreach (var offset in Offsets) + // Its letters' middle on each place tried, the slice's middle first. + var (inkTop, inkBottom) = Ink(lines, size, caption, captionSize); + var rows = RowWidthsOf(lines, size, caption, captionSize); + IReadOnlyList rowHeights = caption is null ? heights : [.. heights, captionHeight]; + var places = turn == 0 ? Positions(zone) : Offsets.Select(o => zone.At((zone.Inner + zone.Outer) / 2 + o * zone.Depth)); + foreach (var place in middleOnly ? places.Take(1) : places) { - var at = zone.At((zone.Inner + zone.Outer) / 2 + offset * zone.Depth); - if (zone.Contains(new LabelBox(at, width, height, turn))) - return new LabelText(lines, heights, size, caption, captionSize, captionHeight, width, height, at, turn, whole); + if (!LabelText.StackInside(zone, place, turn, rows, rowHeights, inkTop, inkBottom)) + continue; + // Upright, it moves sideways to have as much room left of it as right of it. + var at = turn != 0 ? place + : Balanced(place, dx => LabelText.StackInside(zone, new Point(place.X + dx, place.Y), 0, rows, rowHeights, inkTop, inkBottom)); + return new LabelText(lines, heights, size, caption, captionSize, captionHeight, width, height, + LabelText.AnchorFor(at, turn, inkTop, inkBottom), turn, whole, InkTop: inkTop, InkBottom: inkBottom, RowWidths: rows); } return null; } @@ -872,23 +1183,51 @@ internal static FormattedText MakeLine(string text, Typeface face, double size, TextAlignment = TextAlignment.Center, }; - private Size SizeOf(string text, Typeface face, double size) + private Size SizeOf(string text, Typeface face, double size) => Measured(text, face, size).Size; + + // A line's box; the room in it above and below its letters; and how wide its letters reach, centred as the line + // is drawn (a "J" reaches out of its box). From the letters' outlines, which match the drawn letters to half a + // pixel; the font's own extent leaves a pixel or two more. + private (Size Size, double InkTop, double InkBottom, double InkWidth) Measured(string text, Typeface face, double size) { var key = (text, face, size); if (_sizes.TryGetValue(key, out var known)) return known; if (_sizes.Count >= MaxCachedSizes) _sizes.Clear(); - var measured = new FormattedText(DonutMenu.KeepKoreanWords(text), CultureInfo.CurrentUICulture, _fonts.Direction, face, size, null); - return _sizes[key] = new Size(measured.Width, measured.Height); + // A brush, or the text has no outlines to build. + var measured = new FormattedText(DonutMenu.KeepKoreanWords(text), CultureInfo.CurrentUICulture, _fonts.Direction, face, size, Brushes.Black); + var box = new Size(measured.Width, measured.Height); + if (measured.BuildGeometry(new Point(0, 0))?.Bounds is not { Width: > 0, Height: > 0 } ink) + return _sizes[key] = (box, 0, 0, box.Width); + var reach = Math.Max(box.Width / 2 - ink.Left, ink.Right - box.Width / 2); + return _sizes[key] = (box, ink.Top, box.Height - ink.Bottom, 2 * reach); } + /// The widths of a block's rows: its lines, then its caption. + private double[] RowWidthsOf(IReadOnlyList lines, double size, string? caption, double captionSize) => + [.. lines.Select(l => Measured(l, _fonts.Bold, size).InkWidth), .. caption is null ? Array.Empty() : [Measured(caption, _fonts.Regular, captionSize).InkWidth]]; + + /// + /// The room above a block's first line's letters and below its last line's (or caption's). + /// + private (double Top, double Bottom) Ink(IReadOnlyList lines, double size, string? caption, double captionSize) => + lines.Count == 0 ? (0, 0) + : (Measured(lines[0], _fonts.Bold, size).InkTop, + caption is not null ? Measured(caption, _fonts.Regular, captionSize).InkBottom : Measured(lines[^1], _fonts.Bold, size).InkBottom); + // ───────────────────────── Words and lines ───────────────────────── /// /// A name's pieces a line may break between: words, and each character of a Chinese or Japanese word (those break /// anywhere). Space: a space goes before it when it doesn't start a line. /// + private static bool OpensUnit(string element) => element is "(" or "「" or "『" or "【" or "〔" or "〈" or "《" or "(" or "[" or "“" or "‘"; + + private static bool ClosesUnit(string element) => element is ")" or "」" or "』" or "】" or "〕" or "〉" or "》" or ")" or "]" or "”" + or "’" or "、" or "。" or "," or "." or "!" or "?" or ":" or ";" or "ー" or "…" or "・" or "ゃ" or "ゅ" or "ょ" or "っ" or "ャ" + or "ュ" or "ョ" or "ッ" or "ァ" or "ィ" or "ゥ" or "ェ" or "ォ" or "ぁ" or "ぃ" or "ぅ" or "ぇ" or "ぉ"; + internal static List<(string Text, bool Space)> Units(string name) { var units = new List<(string, bool)>(); @@ -899,17 +1238,33 @@ private Size SizeOf(string text, Typeface face, double size) units.Add((word, units.Count > 0)); continue; } - var first = true; + // A line never ends on an opening bracket or starts on a closing one or a full stop (Japanese and Chinese + // line breaking): those stay with the character after or before them. + var pieces = new List(); + var open = ""; foreach (var element in TextElements(word)) { - units.Add((element, first && units.Count > 0)); - first = false; + if (OpensUnit(element)) + open += element; + else if (ClosesUnit(element) && pieces.Count > 0 && open.Length == 0) + pieces[^1] += element; + else + { + pieces.Add(open + element); + open = ""; + } } + if (open.Length > 0) + pieces.Add(open); + for (var p = 0; p < pieces.Count; p++) + units.Add((pieces[p], p == 0 && units.Count > 0)); } return units; } - /// The pieces in lines, split so the widest line is as narrow as can be. + /// + /// The pieces in lines, split so the widest line is as narrow as can be. + /// internal List Split(List<(string Text, bool Space)> units, int count, double size) { string Join(int from, int to) diff --git a/src/Filee.App/ViewModels/IconPickerViewModel.cs b/src/Filee.App/ViewModels/IconPickerViewModel.cs index 57e3f5195..877a30db2 100644 --- a/src/Filee.App/ViewModels/IconPickerViewModel.cs +++ b/src/Filee.App/ViewModels/IconPickerViewModel.cs @@ -16,7 +16,9 @@ public sealed class IconChoice(string key, bool isCurrent) { public string Key { get; } = key; - /// Its key in words, for the tooltip and screen readers: "image-edit-outline" → "Image edit outline". + /// + /// Its key in words, for the tooltip and screen readers: "image-edit-outline" → "Image edit outline". + /// public string Name { get; } = IconPickerViewModel.NameOf(key); public Geometry? Data => SliceIcons.Geometry(Key); @@ -34,8 +36,10 @@ public sealed partial class IconPickerViewModel : ObservableObject private readonly string? _current; private readonly Action _picked; - /// The preset's : null (its default), or a key. - /// Called with the choice: null for Default, for None, else a key. + /// The preset's : null (its default), or + /// a key. + /// Called with the choice: null for Default, for None, else a + /// key. public IconPickerViewModel(ILocalizer loc, string? current, Action picked) { _loc = loc; diff --git a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs index b1161fc81..04e178fc4 100644 --- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs @@ -324,7 +324,9 @@ public bool EditingFront private static string Percent(double value) => (Math.Round(value) / 100).ToString("P0", System.Globalization.CultureInfo.CurrentCulture); - /// How large the selected donut's slice names and icons are, in percent (). + /// + /// How large the selected donut's slice names and icons are, in percent (). + /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(LabelScaleText))] private double _labelScale = 100; @@ -332,7 +334,9 @@ private static string Percent(double value) => /// The Label scale as the slider's value shows it: "125 %". public string LabelScaleText => Percent(LabelScale); - /// What the selected donut's slices show when their preset doesn't say (). + /// + /// What the selected donut's slices show when their preset doesn't say (). + /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(DefaultShow))] private Choice _sliceLabels = null!; diff --git a/src/Filee.App/ViewModels/RadialViewModel.cs b/src/Filee.App/ViewModels/RadialViewModel.cs index 0d087451f..93da48e00 100644 --- a/src/Filee.App/ViewModels/RadialViewModel.cs +++ b/src/Filee.App/ViewModels/RadialViewModel.cs @@ -34,10 +34,14 @@ public sealed partial class RadialViewModel(UserDataStore store, ILocalizer loc, /// How wide a group's ring spreads (Appearance → Group ring spread). [ObservableProperty] private double _groupRingSpread = 1; - /// How large the slices' names and icons are (the donut's ). + /// + /// How large the slices' names and icons are (the donut's ). + /// [ObservableProperty] private double _labelScale = 1; - /// What a slice shows when its preset doesn't say (the donut's ). + /// + /// What a slice shows when its preset doesn't say (the donut's ). + /// [ObservableProperty] private LabelShow _defaultShow = LabelShow.Auto; /// The other face has a slice for these files: Tab flips to it. diff --git a/src/Filee.App/ViewModels/SliceLabelFields.cs b/src/Filee.App/ViewModels/SliceLabelFields.cs index 5f80ea4e3..e287a25ef 100644 --- a/src/Filee.App/ViewModels/SliceLabelFields.cs +++ b/src/Filee.App/ViewModels/SliceLabelFields.cs @@ -1,5 +1,6 @@ // The slice-label fields of the preset and group editors: the slice's icon (picked in the icon picker), a short name -// for the donut, what the slice shows, and which way its name runs. Shared by PresetEditorViewModel and GroupEditorViewModel. +// for the donut, what the slice shows, and which way its name runs. Shared by PresetEditorViewModel and +// GroupEditorViewModel. using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; diff --git a/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs b/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs index e466cf3ec..02f295fdb 100644 --- a/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs +++ b/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs @@ -2,7 +2,9 @@ namespace Filee.App.Views; -/// The slice-label fields of the preset and group editors (). +/// +/// The slice-label fields of the preset and group editors (). +/// public partial class SliceLabelFieldsView : UserControl { public SliceLabelFieldsView() => InitializeComponent(); diff --git a/src/Filee.Core/Localization/ILocalizer.cs b/src/Filee.Core/Localization/ILocalizer.cs index f810c23b2..bd07da86c 100644 --- a/src/Filee.Core/Localization/ILocalizer.cs +++ b/src/Filee.Core/Localization/ILocalizer.cs @@ -64,7 +64,8 @@ public static string DonutName(this ILocalizer loc, Preset preset) /// /// The name on a slice of a group's ring, where the group's name already says what it is about: a built-in /// preset's short name (its + .short, "Left" for "Rotate left") when it has one, - /// otherwise . The preset's own comes first. + /// otherwise . The preset's own + /// comes first. /// public static string RingName(this ILocalizer loc, Preset preset) { diff --git a/src/Filee.Core/Presets/LabelShow.cs b/src/Filee.Core/Presets/LabelShow.cs index fbfdefb52..b72e7e2a0 100644 --- a/src/Filee.Core/Presets/LabelShow.cs +++ b/src/Filee.Core/Presets/LabelShow.cs @@ -12,8 +12,8 @@ namespace Filee.Core.Presets; public enum LabelShow { /// - /// Whichever fits, one style for every Auto slice of a ring that has an icon: icon and name if every name fits whole, - /// else the name if every name fits whole, else the icon. A slice without an icon shows its name. + /// Whichever fits, one style for every Auto slice of a ring that has an icon: icon and name if every name fits + /// whole, else the name if every name fits whole, else the icon. A slice without an icon shows its name. /// Auto, diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 31cd208f7..550143965 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -155,14 +155,20 @@ public sealed class Preset /// meaning "no icon", even where there would be a default. public const string NoIcon = "none"; - /// A shorter name shown on its donut slice instead of the usual one. Null or blank: the usual name. + /// + /// A shorter name shown on its donut slice instead of the usual one. Null or blank: the usual name. + /// public string? ShortName { get; set; } - /// What its donut slice shows. Null: whatever its donut says (). + /// + /// What its donut slice shows. Null: whatever its donut says (). + /// [JsonConverter(typeof(LabelShowJsonConverter))] public LabelShow? Show { get; set; } - /// Which way its donut slice's name runs. Null: whichever reads best there (). + /// + /// Which way its donut slice's name runs. Null: whichever reads best there (). + /// [JsonConverter(typeof(LabelDirectionJsonConverter))] public LabelDirection? Direction { get; set; } diff --git a/src/Filee.Core/Profiles/ToolbarProfile.cs b/src/Filee.Core/Profiles/ToolbarProfile.cs index 4f31d3e5f..49401a18e 100644 --- a/src/Filee.Core/Profiles/ToolbarProfile.cs +++ b/src/Filee.Core/Profiles/ToolbarProfile.cs @@ -52,14 +52,16 @@ public sealed class ToolbarProfile /// /// How large its slices' names and icons are, from to in - /// steps of : 1 is 13 px names on a donut of the default size. They also grow and shrink - /// with the donut. + /// steps of : 1 is 13 px names on a donut of the default size. They also grow and + /// shrink with the donut. /// public double LabelScale { get; set; } = 1; public const double MinLabelScale = 0.8, MaxLabelScale = 1.6, LabelScaleStep = 0.05; - /// within the allowed range, on a step; 1 for a value that isn't a number. + /// + /// within the allowed range, on a step; 1 for a value that isn't a number. + /// public static double ClampLabelScale(double scale) => double.IsFinite(scale) ? Math.Round(Math.Round(Math.Clamp(scale, MinLabelScale, MaxLabelScale) / LabelScaleStep) * LabelScaleStep, 2) diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 7f46fc534..3037c9146 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -82,26 +82,30 @@ public sealed class DonutSettings public bool ShowCaptions { get; set; } = true; /// - /// How large the ring a group fans out is, against its size on a donut of the same radius before this setting - /// ( to ): its depth, its gap from the donut and its labels. + /// How large the ring a group fans out is, against its size on a donut of the same radius before this setting ( to ): its depth, its gap from the donut and its labels. /// A missing value reads as 1, today's ring, so it needs no schema step. /// public double GroupRingSize { get; set; } = 1; /// /// How wide the ring a group fans out spreads, against the default fan (the group's slice and a quarter of each - /// neighbour): to . Wider slices have room for longer - /// names. A missing value reads as 1. + /// neighbour): to . Wider slices have room for + /// longer names. A missing value reads as 1. /// public double GroupRingSpread { get; set; } = 1; public const double MinGroupRingSize = 0.75, MaxGroupRingSize = 1.5, GroupRingSizeStep = 0.05; public const double MinGroupRingSpread = 1, MaxGroupRingSpread = 3, GroupRingSpreadStep = 0.1; - /// within the allowed range, on a step; 1 for a value that isn't a number. + /// + /// within the allowed range, on a step; 1 for a value that isn't a number. + /// public static double ClampGroupRingSize(double size) => OnStep(size, MinGroupRingSize, MaxGroupRingSize, GroupRingSizeStep); - /// within the allowed range, on a step; 1 for a value that isn't a number. + /// + /// within the allowed range, on a step; 1 for a value that isn't a number. + /// public static double ClampGroupRingSpread(double spread) => OnStep(spread, MinGroupRingSpread, MaxGroupRingSpread, GroupRingSpreadStep); diff --git a/tests/Filee.App.Tests/DonutGroupMarkTests.cs b/tests/Filee.App.Tests/DonutGroupMarkTests.cs index 2c95122de..92f14b44e 100644 --- a/tests/Filee.App.Tests/DonutGroupMarkTests.cs +++ b/tests/Filee.App.Tests/DonutGroupMarkTests.cs @@ -90,12 +90,13 @@ public void The_chevron_is_as_large_as_the_donut_says() } [AvaloniaFact] - public void A_groups_name_moves_in_to_leave_the_chevron_room() + public void A_groups_name_sits_where_any_other_name_does() { - // The first row the name's ink reaches, the name being what differs from the same slice without one. + // The first row the name's ink reaches, the name being what differs from the same slice without one: a group's + // name keeps clear of its chevron, not of a band across the slice, and is centred as any other's. int NameTop(bool group) => Changed(Render(group, label: "Rotate"), Render(group)).Min() / Picture; - Assert.InRange(NameTop(true) - NameTop(false), 2, 4); + Assert.InRange(NameTop(true) - NameTop(false), -1, 1); } [AvaloniaFact] diff --git a/tests/Filee.App.Tests/RadialLabelTests.cs b/tests/Filee.App.Tests/RadialLabelTests.cs index 301855b31..0628d96b4 100644 --- a/tests/Filee.App.Tests/RadialLabelTests.cs +++ b/tests/Filee.App.Tests/RadialLabelTests.cs @@ -31,7 +31,8 @@ public void A_slice_gets_its_presets_own_icon_its_default_or_none() { vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); Assert.Equal("crop", ItemFor(vm, "tool-crop").IconKey); // the built-in default - Assert.Null(ItemFor(vm, "to-avif").IconKey); // a format conversion + vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: false); + Assert.Null(ItemFor(vm, "to-png").IconKey); // a format conversion, on the front crop.Icon = "star-outline"; vm.Load([@"C:\photos\a.jpg"], clickMode: true, backFace: true); diff --git a/tests/Filee.App.Tests/SliceIconsTests.cs b/tests/Filee.App.Tests/SliceIconsTests.cs index 61a4b913e..f7e9feaae 100644 --- a/tests/Filee.App.Tests/SliceIconsTests.cs +++ b/tests/Filee.App.Tests/SliceIconsTests.cs @@ -43,8 +43,6 @@ public void Built_in_presets_that_do_more_than_convert_have_an_icon_and_format_c else Assert.Equal(PresetKind.Convert, preset.Kind); // a conversion to a format, named by it } - // 48 here (the 53 reviewed, less the Actions branch's 6, plus AI Captions) and 89 format conversions without one. - Assert.Equal(89, BuiltInData.CreatePresets().Count(p => SliceIcons.DefaultFor(p) is null)); // The ones the user reviewed, spot-checked. Assert.Equal("crop", SliceIcons.DefaultFor(Find("tool-crop"))); Assert.Equal("auto-fix", SliceIcons.DefaultFor(Find("remove-background"))); @@ -53,6 +51,13 @@ public void Built_in_presets_that_do_more_than_convert_have_an_icon_and_format_c Assert.Null(SliceIcons.DefaultFor(Find("mp4-720"))); } + [Fact] + public void Every_preset_given_an_icon_is_a_built_in_one() + { + var ids = BuiltInData.CreatePresets().Select(p => p.Id).ToHashSet(); + Assert.All(SliceIcons.DefaultIds, id => Assert.Contains(id, ids)); + } + [Fact] public void Every_built_in_group_and_tool_has_an_icon() { diff --git a/tests/Filee.App.Tests/SliceLabelFitTests.cs b/tests/Filee.App.Tests/SliceLabelFitTests.cs index 6bb128aaa..0bfb876dc 100644 --- a/tests/Filee.App.Tests/SliceLabelFitTests.cs +++ b/tests/Filee.App.Tests/SliceLabelFitTests.cs @@ -1,11 +1,11 @@ // Every built-in donut, front and back and every group's ring (at three ring sizes and spreads), at the smallest, -// default and largest donut, at Label scale 80 / 100 / 160 %, in the four Show modes and every language: each label -// stays inside its slice's safe zone, a whole name keeps its words whole (Chinese and Japanese may break between -// characters), and a ring has one name size and one icon size. And never a stub: with Auto or Icon & text, a ring's -// slices that have an icon show one style, a name with an icon is shortened (Icon & text only) to 60 % of its letters -// at most, an icon never comes with a caption, names alone show their captions on all or none, and the face's names -// are 11 px × the donut's size at least (never under 10 px, or as small as the Label scale asks). Layout only, nothing -// is drawn. +// default and largest donut, at Label scale 80 / 100 / 160 %, in the four Show modes, with Show output format on and +// off, and every language: each label stays inside its slice's safe zone, a whole name keeps its words whole (Chinese +// and Japanese may break between characters), and a ring has one name size, one icon size and one direction for its +// names. And: Auto shows icons on all of a ring's names or on none, Icon & text keeps 60 % of a shortened name's +// letters at least, a slice that needs its caption never shows its icon alone (but in Icon only), slices of one name +// look alike, and the face's names are 10 px × the donut's size at least (never under 10 px, or as small as the Label +// scale asks). Layout only, nothing is drawn. using Avalonia.Headless.XUnit; using Avalonia.Media; @@ -54,21 +54,23 @@ public void Every_label_of_every_built_in_donut_fits_its_slice_in_every_language { donut.Items = Items(ids, store, loc); foreach (var (radius, scale, show, ring) in Settings) - { - donut.OuterRadius = radius; - donut.LabelScale = scale; - donut.DefaultShow = show; - (donut.GroupRingSize, donut.GroupRingSpread) = ring; - var (face, rings) = donut.PlanLabels(bold, regular); - var where = $"{language} {profile.Id} {faceName} r{radius} ×{scale} {show} ring {ring.Size}/{ring.Spread}"; - Check(face, donut.Items, where, problems, show); - var floor = Math.Min(SliceLabelLayout.NameSizeFor(scale, donut.DonutScale), - Math.Max(SliceLabelLayout.MinNameSize, SliceLabelLayout.MinStepNameSize * donut.DonutScale)); - if (face.Select(l => l.Text?.Size).OfType().Any(size => size < floor - 1e-9)) - problems.Add($"{where}: names under {floor:0.#} px"); - for (var g = 0; g < rings.Count; g++) - Check(rings[g], donut.Items[g].Children ?? [], $"{where} ring {donut.Items[g].Id}", problems, show); - } + foreach (var captions in new[] { true, false }) + { + donut.ShowCaptions = captions; + donut.OuterRadius = radius; + donut.LabelScale = scale; + donut.DefaultShow = show; + (donut.GroupRingSize, donut.GroupRingSpread) = ring; + var (face, rings) = donut.PlanLabels(bold, regular); + var where = $"{language} {profile.Id} {faceName} r{radius} ×{scale} {show}{(captions ? "" : " off")} ring {ring.Size}/{ring.Spread}"; + Check(face, donut.Items, where, problems, show); + var floor = Math.Min(SliceLabelLayout.NameSizeFor(scale, donut.DonutScale), + Math.Max(SliceLabelLayout.MinNameSize, SliceLabelLayout.MinStepNameSize * donut.DonutScale)); + if (face.Select(l => l.Text?.Size).OfType().Any(size => size < floor - 1e-9)) + problems.Add($"{where}: names under {floor:0.#} px"); + for (var g = 0; g < rings.Count; g++) + Check(rings[g], donut.Items[g].Children ?? [], $"{where} ring {donut.Items[g].Id}", problems, show); + } } } } @@ -79,6 +81,76 @@ public void Every_label_of_every_built_in_donut_fits_its_slice_in_every_language Assert.True(problems.Count == 0, $"{problems.Count} problems:\n{string.Join("\n", problems.Take(40))}"); } + // The English faces as a file of each donut's own format shows them: "Same as source" presets captioned with that + // format, as the live donut does. + private static List<(string Face, List Items, IReadOnlyList Labels)> EnglishFaces(LabelShow show = LabelShow.Auto) + { + TestServices.EnsureInitialized("en"); + var loc = AppHost.Get(); + var store = AppHost.Get(); + var family = SoftPalette.FontFamilyFor("en"); + var bold = new Typeface(family, FontStyle.Normal, FontWeight.SemiBold); + var regular = new Typeface(family); + var faces = new List<(string, List, IReadOnlyList)>(); + foreach (var profile in BuiltInData.CreateProfiles()) + foreach (var (faceName, ids) in new[] { ("front", profile.PresetIds), ("back", profile.BackPresetIds ?? []) }) + { + var format = (profile.Extensions.FirstOrDefault() ?? "pdf").ToUpperInvariant(); + var items = Items(ids, store, loc).Select(i => i.Caption != "JPG/PNG" ? i : new DonutItem + { + Id = i.Id, + Label = i.Label, + Caption = format, + FullName = i.FullName, + IconKey = i.IconKey, + KeepsCaption = i.KeepsCaption, + Children = i.Children, + }).ToList(); + if (items.Count == 0) + continue; + var donut = new DonutMenu { Items = items, ShowCaptions = true, DefaultShow = show }; + faces.Add(($"{profile.Id} {faceName}", items, donut.PlanLabels(bold, regular).Face)); + } + return faces; + } + + [AvaloniaFact] + public void English_names_sit_on_their_slices_middle_as_today() + { + // Every name of every English face, with its caption, its letters centred on its slice's middle (today's donut + // draws them there; the safe zone once kept 10 px or more round them and pushed some toward the rim). + var off = EnglishFaces().SelectMany(f => f.Labels.Select((l, i) => (f.Face, Name: f.Items[i].Label, Label: l))) + .Where(x => x.Label.Text is not null && !SliceLabelLayout.OnMiddle(x.Label)) + .Select(x => $"{x.Face}: \"{x.Name}\"").ToList(); + Assert.True(off.Count == 0, string.Join("\n", off)); + } + + [AvaloniaFact] + public void English_names_keep_the_captions_today_shows() + { + // As today: "Remove metadata" with PDF under it on the PDF back face, "Compress image" with PNG on the image + // back face, "Crop video" with "Group of 6" beside its chevron (once kept off a band across the slice). + var lost = EnglishFaces().SelectMany(f => f.Labels.Select((l, i) => (f.Face, Item: f.Items[i], Label: l))) + .Where(x => x.Label.Text is { Caption: null } && x.Item.Caption is { Length: > 0 } c && + !string.Equals(c, x.Item.Label, StringComparison.OrdinalIgnoreCase)) + .Select(x => $"{x.Face}: \"{x.Item.Label}\" without \"{x.Item.Caption}\"").ToList(); + Assert.True(lost.Count == 0, string.Join("\n", lost)); + } + + [AvaloniaFact] + public void Auto_shows_icons_only_where_they_cost_nothing() + { + // The image back face has room in every slice for icon, name and caption, centred: icons. The PDF back face + // has room for icon and name in every slice, but not for everything each shows without its icon: names, with + // their captions, as today. + var faces = EnglishFaces(); + var (_, _, images) = faces.Single(f => f.Face == "images back"); + Assert.All(images, l => Assert.Equal(SliceLabelKind.IconAndText, l.Kind)); + Assert.All(images, l => Assert.NotNull(l.Text!.Caption)); + var (_, items, pdf) = faces.Single(f => f.Face == "pdf back"); + Assert.All(pdf.Where((_, i) => items[i].IconKey is not null), l => Assert.Equal(SliceLabelKind.Text, l.Kind)); + } + // The slices as the live donut gives them (RadialViewModel.ItemOf), without checking them against files. internal static List Items(IEnumerable ids, UserDataStore store, ILocalizer loc, LabelDirection? direction = null) { @@ -90,6 +162,7 @@ internal static List Items(IEnumerable ids, UserDataStore sto FullName = loc.DisplayName(p), IconKey = SliceIcons.For(p), Direction = direction, + KeepsCaption = !ring && p.IsCopyToFolder, }; return ids.Select(store.FindPreset).OfType().Select(p => { @@ -113,9 +186,15 @@ internal static List Items(IEnumerable ids, UserDataStore sto private static void Check(IReadOnlyList ring, IReadOnlyList items, string where, List problems, LabelShow show) { - var styles = ring.Where((_, i) => items[i].IconKey is not null && items[i].Label.Length > 0).Select(l => l.Kind).Distinct().ToList(); - if (show is LabelShow.Auto or LabelShow.IconAndText && styles.Count > 1) - problems.Add($"{where}: mixed styles {string.Join(", ", styles)}"); + // Slices of one name look alike: the caption is all that tells them apart. + foreach (var same in Enumerable.Range(0, ring.Count).Where(i => items[i].IconKey is not null && items[i].Label.Length > 0) + .GroupBy(i => items[i].Label, StringComparer.OrdinalIgnoreCase).Where(g => g.Count() > 1)) + if (same.Select(i => ring[i].Kind).Distinct().Count() > 1) + problems.Add($"{where}: \"{same.Key}\" shown as {string.Join(", ", same.Select(i => ring[i].Kind))}"); + // Auto: icons on all of the ring's names or on none (an icon alone, the last resort, aside). + if (show == LabelShow.Auto && ring.Where((l, i) => items[i].IconKey is not null && items[i].Label.Length > 0 && + l.Kind is SliceLabelKind.Text or SliceLabelKind.IconAndText).Select(l => l.Kind).Distinct().Count() > 1) + problems.Add($"{where}: Auto mixes icons and names alone"); // One direction for the ring's names: straight, along their slices, bent along the ring or level with it (two // can look alike on some slices). A preset's own direction keeps its slice out of it. var ways = new HashSet { "straight", "along", "bent", "level" }; @@ -137,11 +216,6 @@ private static void Check(IReadOnlyList ring, IReadOnlyList l.Kind == SliceLabelKind.Text && !string.IsNullOrEmpty(items[i].Caption) && - !string.Equals(items[i].Caption, items[i].Label, StringComparison.OrdinalIgnoreCase)).ToList(); - var shown = captioned.Count(l => l.Text!.Caption is not null); - if (shown > 0 && shown < captioned.Count) - problems.Add($"{where}: captions on {shown} of {captioned.Count} names"); if (ring.Select(l => l.Text?.Size).OfType().Distinct().Count() > 1) problems.Add($"{where}: name sizes {string.Join(", ", ring.Select(l => l.Text?.Size).OfType().Distinct())}"); if (ring.Where(l => l.IconSize > 0).Select(l => l.IconSize).Distinct().Count() > 1) @@ -152,15 +226,28 @@ private static void Check(IReadOnlyList ring, IReadOnlyList 0) problems.Add($"{where}: \"{name}\" shows nothing"); - if (label.Kind == SliceLabelKind.IconAndText && label.Text?.Caption is not null) - problems.Add($"{where}: \"{name}\" has an icon and a caption"); - // A format's name (no icon to fall back on) is shortened as far as it takes on the smallest donuts, as - // Text only is: the centre names it in full. - if (label.Text is { Whole: false } cut && show != LabelShow.TextOnly && items[i].IconKey is not null) + var needs = items[i].KeepsCaption || + items.Any(o => !ReferenceEquals(o, items[i]) && string.Equals(o.Label, name, StringComparison.OrdinalIgnoreCase)); + if (label.Kind == SliceLabelKind.Icon && show != LabelShow.IconOnly && needs) + problems.Add($"{where}: \"{name}\" shows its icon alone without the caption it needs"); + // Icon & text shortens a name beside its icon to MinKept at most; Auto and Text only shorten a name alone + // further only where nothing else fits (the centre names the slice in full). + // Its letters on every line count ("Remove / metad…"), spaces and line breaks not. + if (label is { Kind: SliceLabelKind.IconAndText, Text: { Whole: false } cut }) { - var kept = new System.Globalization.StringInfo(cut.Lines[0].TrimEnd('…').TrimEnd()).LengthInTextElements; - if (kept < SliceLabelLayout.MinKept(name) || (show == LabelShow.Auto && items[i].IconKey is not null)) - problems.Add($"{where}: \"{name}\" cut to \"{cut.Lines[0]}\""); + static List Elements(string s) + { + var list = new List(); + var e = System.Globalization.StringInfo.GetTextElementEnumerator(s); + while (e.MoveNext()) + list.Add(e.GetTextElement()); + return list; + } + static int Letters(IEnumerable elements) => elements.Count(e => !string.IsNullOrWhiteSpace(e)); + var kept = Letters(Elements(string.Concat(cut.Lines).TrimEnd().TrimEnd('…'))); + var need = Letters(Elements(name.Trim()).Take(SliceLabelLayout.MinKept(name))); + if (kept < need) + problems.Add($"{where}: \"{name}\" cut to \"{string.Join(" / ", cut.Lines)}\" beside its icon"); } if (label.Text is { } text) { diff --git a/tests/Filee.App.Tests/SliceLabelLayoutTests.cs b/tests/Filee.App.Tests/SliceLabelLayoutTests.cs index a1dd176e7..929fd8e1c 100644 --- a/tests/Filee.App.Tests/SliceLabelLayoutTests.cs +++ b/tests/Filee.App.Tests/SliceLabelLayoutTests.cs @@ -1,6 +1,7 @@ // How slice labels fit their slices: the safe zone (inset on every side, clear of a group's chevron, the inner edge // curving in), names wrapped at word breaks and never split (except Chinese and Japanese), drawn straight or along the -// slice's longer side (out from the centre, or level with a group ring), icons above names or alone and centred, and one size and one style per ring (Show, Auto). +// slice's longer side (out from the centre, or level with a group ring), icons above names or alone and centred, one +// size per ring, and in Auto one style per ring (Show). using Avalonia; using Avalonia.Controls; @@ -21,10 +22,15 @@ public class SliceLabelLayoutTests // The default donut: radius 150, hole 42 %, 12 slices. private const double Outer = 150, Inner = 63, Gap = 3; - private static SliceShape Shape(int index, int count, double outerKeepOut = 0) => - new(Center, Inner, Outer, DonutGeometry.MidAngle(index, count), DonutGeometry.Span(count), Gap, outerKeepOut); + // The default donut's chevron: 9.25 px in from the outer edge, reaching 6.2 px round that (DonutMenu.ChevronIn). + private const double ChevronIn = 9.25, ChevronReach = 6.18; - // The app's own font (Noto Sans): the headless test font's lines are shorter, which would make names fit that don't. + private static SliceShape Shape(int index, int count, bool group = false) => + new(Center, Inner, Outer, DonutGeometry.MidAngle(index, count), DonutGeometry.Span(count), Gap, + group ? ChevronIn : 0, group ? ChevronReach : 0); + + // The app's own font (Noto Sans): the headless test font's lines are shorter, which would make names fit that + // don't. private static SliceLabelLayout Layout() { TestServices.EnsureInitialized("en"); @@ -43,23 +49,31 @@ private static SliceLabelInput Slice(string name, int index, int count, LabelSho public void The_zone_is_the_slice_inset_on_every_side() { var zone = SafeZone.Of(Shape(0, 12), scale: 1); - var margin = 0.08 * (Outer - Inner); // 6.96: more than the 6 px floor + var margin = SafeZone.MarginShare * (Outer - Inner); // 2.2: more than the 2 px floor + Assert.True(margin > SafeZone.MinMargin); Assert.Equal(Inner + margin, zone.Inner, 6); Assert.Equal(Outer - margin, zone.Outer, 6); Assert.Equal(margin + Gap / 2, zone.Side, 6); - // A thin ring: the 6 px floor, scaled with the donut. + // A thin ring: the floor, scaled with the donut. var thin = SafeZone.Of(new SliceShape(Center, 100, 140, 0, 0.5, Gap), scale: 1.6); - Assert.Equal(100 + 6 * 1.6, thin.Inner, 6); + Assert.Equal(100 + SafeZone.MinMargin * 1.6, thin.Inner, 6); } [Fact] - public void A_group_slice_keeps_clear_of_its_chevron() + public void A_group_slice_keeps_clear_of_its_chevron_only() { - var plain = SafeZone.Of(Shape(0, 12), 1); - var group = SafeZone.Of(Shape(0, 12, outerKeepOut: 15.5), 1); - Assert.Equal(Outer - 15.5, group.Outer, 6); - Assert.True(group.Outer < plain.Outer); + // Clear of the mark by its outer edge, with the slice's middle where it is without one: a label is centred on + // the slice, and only the chevron itself keeps it away (a band across the slice moved it towards the hub). + var plain = SafeZone.Of(Shape(0, 8), 1); + var group = SafeZone.Of(Shape(0, 8, group: true), 1); + Assert.Equal(plain.Inner, group.Inner, 6); + Assert.Equal(plain.Outer, group.Outer, 6); + var chevron = group.At(Outer - ChevronIn); + Assert.False(group.Contains(chevron)); + Assert.False(group.Contains(new LabelBox(group.At(Outer - ChevronIn - 8), 30, 8, 0))); + // Beside it, by the outer edge, a box still fits. + Assert.True(group.Contains(new LabelBox(DonutGeometry.PointAt(Center, Outer - 14, 0.28), 10, 6, 0))); } [Fact] @@ -96,10 +110,13 @@ public void Nothing_near_a_side_edge_fits() // ───────────────────────── Words, lines and direction ───────────────────────── [AvaloniaTheory] - [InlineData("Merge images", 1.0)] + // In Noto Sans, the app's font. At 100 % "Merge images", "Compress image" and "Remove metadata" are too wide for a + // full donut, and "Remove background" is at any Label scale: icons (Auto) or "…". [InlineData("Grayscale", 1.0)] - [InlineData("Remove metadata", 0.8)] // at 100 % its words are too wide for a full donut: icons (Auto) or "…" - [InlineData("Remove background", 0.8)] + [InlineData("Merge PDF", 1.0)] + [InlineData("Merge images", 0.9)] + [InlineData("Remove metadata", 0.8)] + [InlineData("Compress image", 0.8)] public void Names_fit_whole_on_every_slice_of_a_full_donut(string name, double labelScale) { var layout = Layout(); @@ -203,8 +220,9 @@ public void Text_runs_along_a_slices_longer_side() [InlineData(2.6)] // the bottom half, read the other way up public void Names_on_a_group_ring_bend_along_it_and_show_whole(double mid) { - var zone = SafeZone.Of(RingShape(mid, 0.5), 1); - foreach (var name in new[] { "Open in Terminal", "Copy to…", "Move to…", "AirDrop", "Add to Shelf" }) + // Measured in Noto Sans, the app's font: at 18.6 px these bend whole round a slice 0.7 rad wide. + var zone = SafeZone.Of(RingShape(mid, 0.7), 1); + foreach (var name in new[] { "Rotate left", "Resize for email", "Copy to…", "Move to…" }) { var block = Layout().FitText(zone, name, null, 18.6, 15, alongToo: true); Assert.True(block is { Whole: true, Curved: true }, $"{name} at {mid}"); @@ -212,6 +230,97 @@ public void Names_on_a_group_ring_bend_along_it_and_show_whole(double mid) } } + [AvaloniaTheory] + [InlineData(0.0)] // top: the icon nearer the rim + [InlineData(Math.PI)] // bottom: read the other way up, the icon nearer the centre + public void On_a_deep_enough_slice_the_icon_stands_above_a_bent_name_as_read(double mid) + { + // A slice of the six-slice face, its name bent along the ring (a preset's Text direction): the icon above it as + // read, on the slice's middle line, as in every other mode, rather than beside it. + var zone = SafeZone.Of(new SliceShape(Center, Inner, Outer, mid, DonutGeometry.Span(6), Gap), 1); + var fit = Layout().FitIconAndText(zone, "Remove background", null, 11, 9, 20 * 11 / 13.0, 1, shorten: false, + LabelDirection.AlongRing)!.Value; + Assert.True(fit.Text is { Curved: true, Whole: true }); + static double Radius(Point p) => Math.Sqrt(Math.Pow(p.X - Center.X, 2) + Math.Pow(p.Y - Center.Y, 2)); + var (icon, text) = (Radius(fit.Icon), Radius(fit.Text.Anchor)); + Assert.True(mid == 0 ? icon > text : icon < text, $"icon at {icon:0.#}, name at {text:0.#}"); + Assert.Equal(0, Math.IEEERemainder(DonutGeometry.AngleOf(Center, fit.Icon) - mid, 2 * Math.PI), 2); + } + + [AvaloniaFact] + public void A_face_bent_along_the_ring_puts_every_icon_above_its_name() + { + // Icon & text on the six-slice face, every preset's Text direction Along the ring: one placement for all its + // icons, above each name as read (nearer the rim on the top half, the centre on the bottom), stepping the names + // down for it rather than putting some icons beside their names. + string[] names = ["Remove background", "Rotate left", "Compress image", "Merge images", "Trace", "One ZIP"]; + var slices = names.Select((n, i) => Slice(n, i, 6, LabelShow.IconAndText, "crop") with { Direction = LabelDirection.AlongRing }).ToList(); + var plan = Layout().PlanRing(slices, 1, 1, showCaptions: true); + static double Radius(Point p) => Math.Sqrt(Math.Pow(p.X - Center.X, 2) + Math.Pow(p.Y - Center.Y, 2)); + foreach (var l in plan) + { + Assert.Equal(SliceLabelKind.IconAndText, l.Kind); + Assert.True(l.Text!.Curved); + var bottom = Math.Cos(l.Zone.Mid) < 0; + Assert.True(bottom ? Radius(l.IconCenter) < Radius(l.Text.Anchor) : Radius(l.IconCenter) > Radius(l.Text.Anchor)); + Assert.Equal(0, Math.IEEERemainder(DonutGeometry.AngleOf(Center, l.IconCenter) - l.Zone.Mid, 2 * Math.PI), 2); + } + } + + [AvaloniaTheory] + [InlineData("左", "右", "ページ")] // Japanese + [InlineData("左", "右", "页面")] // Chinese + [InlineData("왼쪽", "오른쪽", "페이지")] // Korean + public void Chinese_Japanese_and_Korean_stay_upright_on_a_group_ring(string a, string b, string c) + { + // A wide ring, where Latin names bend along it: these stay straight, their characters upright. + List ring = [.. new[] { a, b, c }.Select((n, k) => new SliceLabelInput(n, null, null, LabelShow.TextOnly, + RingShape(-0.6 + 0.6 * k, 0.6)))]; + var plan = Layout().PlanRing(ring, 1, 1, false, ring: true); + Assert.All(plan, l => Assert.True(l.Text is { Curved: false, Rotation: 0 }, string.Join("", l.Text?.Lines ?? []))); + var latin = Layout().PlanRing([.. ring.Select((s, k) => s with { Name = new[] { "Left", "Right", "Pages" }[k] })], 1, 1, false, ring: true); + Assert.Contains(latin, l => l.Text is { Curved: true } || l.Text?.Rotation != 0); + } + + [AvaloniaFact] + public void On_an_aslant_slice_a_short_name_stays_on_one_line_at_its_full_size() + { + // A three-slice face's slice at eight o'clock: "Merge PDFs" on one line at its full size, inside the slice + // (once it fitted only moved round from the middle line; it now fits there, moved sideways to its free width). + var zone = SafeZone.Of(new SliceShape(Center, Inner, Outer, DonutGeometry.MidAngle(2, 3), DonutGeometry.Span(3), Gap), 1); + var block = Layout().FitText(zone, "Merge PDFs", null, 13, 10.5, alongToo: false)!; + Assert.Single(block.Lines); + Assert.Equal(13, block.Size); + Assert.True(block.Inside(zone)); + } + + [AvaloniaFact] + public void One_long_word_doesnt_make_every_name_of_the_face_smaller() + { + // "PDF zusammenführen" fits whole on no slice of an eight-slice face at 13 px, nor shortened to MinKept; cut so + // it still reads ("PDF zusam…"), it keeps the face at 13 px rather than make every other name smaller. + string[] names = ["PDF zusammenführen", "Crop", "Rotate", "Flip", "Grayscale", "TIFF", "HTML", "ZIP"]; + var plan = Layout().PlanRing([.. names.Select((n, i) => Slice(n, i, names.Length))], 1, 1, false); + Assert.All(plan, l => Assert.Equal(13, l.Text!.Size)); + Assert.False(plan[0].NameWhole); + } + + [AvaloniaFact] + public void A_name_is_not_cut_by_a_letter_or_two_where_a_half_step_smaller_shows_it_whole() + { + // Find a face size at which a name only just doesn't fit whole: the ring steps down rather than cut it so + // little. + var layout = Layout(); + foreach (var name in new[] { "Videos zusammen", "Remove backgrounds", "Merge picture files" }) + foreach (var count in new[] { 6, 8, 12 }) + { + var plan = layout.PlanRing([.. Enumerable.Range(0, count).Select(i => Slice(i == 0 ? name : "Crop", i, count))], 1, 1, false); + if (plan[0].Text is { Whole: false } cut) + Assert.True(name.Count(c => c != ' ') - cut.Lines.Sum(l => l.TrimEnd('…').Count(c => c != ' ')) > 2, + $"{name} on {count}: \"{string.Join(" ", cut.Lines)}\" at {cut.Size}"); + } + } + [AvaloniaFact] public void Bent_names_keep_more_than_straight_ones_where_the_ring_is_tight() { @@ -239,7 +348,8 @@ public void Bent_letters_run_round_the_ring_upright(double mid) var outward = DonutGeometry.AngleOf(Center, g.At); Assert.Equal(0, Math.Sin(g.Rotation - outward - (SliceLabelLayout.Bottom(mid) ? Math.PI : 0)), 6); } - // Each letter follows the last clockwise on the top half, anticlockwise on the bottom half: left to right as read. + // Each letter follows the last clockwise on the top half, anticlockwise on the bottom half: left to right as + // read. for (var i = 1; i < glyphs.Count; i++) { var turn = Math.IEEERemainder(DonutGeometry.AngleOf(Center, glyphs[i].At) - DonutGeometry.AngleOf(Center, glyphs[i - 1].At), 2 * Math.PI); @@ -256,11 +366,15 @@ public void Straight_wins_when_both_directions_fit() } [AvaloniaFact] - public void A_name_too_long_to_fit_is_shortened_on_one_line_where_allowed() + public void A_name_too_long_to_fit_is_shortened_keeping_the_most_letters_that_fit() { - var block = Layout().FitShortened(SafeZone.Of(Shape(3, 12), 1), "Trim transparent borders", 13, alongToo: true)!; + // On as many lines as keep the most of it: "Trim / transpare…". + var zone = SafeZone.Of(Shape(3, 12), 1); + var block = Layout().FitShortened(zone, "Trim transparent borders", 13, alongToo: true)!; Assert.False(block.Whole); - Assert.EndsWith("…", Assert.Single(block.Lines)); + Assert.EndsWith("…", block.Lines[^1]); + Assert.True(block.Inside(zone)); + Assert.True(string.Concat(block.Lines).Length > "Trim tran…".Length); } // ───────────────────────── Rings ───────────────────────── @@ -302,11 +416,11 @@ public void Auto_slices_without_an_icon_show_their_name_and_explicit_slices_keep Slice("Crop", 2, 12, LabelShow.TextOnly, "crop"), Slice("Crop", 3, 12, LabelShow.IconOnly, "crop"), ], 1, 1, false); - Assert.Equal(SliceLabelKind.Icon, plan[0].Kind); // its name can't fit whole: the ring's Auto slices go to icons + Assert.Equal(SliceLabelKind.Text, plan[0].Kind); // its name can't fit whole: shortened, not its icon alone + Assert.False(plan[0].NameWhole); Assert.Equal(SliceLabelKind.Text, plan[1].Kind); // no icon: its name Assert.Equal(SliceLabelKind.Text, plan[2].Kind); // Text only stays text Assert.Equal(SliceLabelKind.Icon, plan[3].Kind); // Icon only stays an icon - Assert.Equal(plan[0].IconSize, plan[3].IconSize); // one icon size for the ring } [AvaloniaFact] @@ -332,9 +446,9 @@ public void An_icon_alone_is_centred_and_an_icon_with_a_name_sits_above_it_uprig public void On_a_wide_slice_the_icon_stands_before_a_name_bent_along_the_ring(double mid) { // Where an icon above its name would only fit on the slices that run up and down: before it, on every slice. - var zone = SafeZone.Of(RingShape(mid, 0.5), 1); + var zone = SafeZone.Of(RingShape(mid, 0.6), 1); var layout = Layout(); - var fit = layout.FitIconAndText(zone, "Copy path", null, 18.6, 15, 20 * 1.4, 1.4, shorten: false)!.Value; + var fit = layout.FitIconAndText(zone, "Rotate left", null, 18.6, 15, 20 * 1.4, 1.4, shorten: false)!.Value; Assert.True(fit.Text is { Curved: true, Whole: true }); Assert.True(fit.Text.Inside(zone)); Assert.True(zone.Contains(fit.Icon, SliceLabel.IconReach(20 * 1.4))); @@ -347,8 +461,9 @@ public void On_a_wide_slice_the_icon_stands_before_a_name_bent_along_the_ring(do [AvaloniaFact] public void Where_the_name_cant_show_whole_under_its_icon_the_icon_stands_before_it_out_from_the_centre() { - // A deep, narrow slice: "Line art" under its icon would be cut; before it, along the slice, it shows whole. - var zone = SafeZone.Of(new SliceShape(Center, 67, 160, DonutGeometry.MidAngle(2, 12), DonutGeometry.Span(12), Gap), 160 / 150.0); + // A deep, narrow slice (a fourteen-slice face): "Pixelate" under its icon would be cut; before it, along the + // slice, it shows whole. + var zone = SafeZone.Of(new SliceShape(Center, 67, 160, DonutGeometry.MidAngle(2, 14), DonutGeometry.Span(14), Gap), 160 / 150.0); var layout = Layout(); var fit = layout.FitIconAndText(zone, "Pixelate", null, 11.1, 9, 20 * 0.85, 0.85, shorten: true)!.Value; Assert.True(fit.Text.Whole); @@ -358,6 +473,20 @@ public void Where_the_name_cant_show_whole_under_its_icon_the_icon_stands_before private static double Distance(Point p) => Math.Sqrt(Math.Pow(p.X - Center.X, 2) + Math.Pow(p.Y - Center.Y, 2)); + [AvaloniaFact] + public void A_name_shortened_under_its_icon_wraps_as_a_name_alone_does() + { + // The eight-slice face's top slice at 13 px: "Remove background" doesn't fit whole under its icon, and on one + // line no cut that still reads does; on two lines it keeps "Remove / backgr…". + var fit = Layout().FitIconAndText(SafeZone.Of(Shape(0, 8), 1), "Remove background", null, 13, 10.5, 20, 1, shorten: true, + LabelDirection.Straight, SliceLabelLayout.MinKept("Remove background"), placement: IconPlacement.Above); + Assert.NotNull(fit); + Assert.False(fit.Value.Text.Whole); + Assert.Equal(2, fit.Value.Text.Lines.Count); + Assert.Equal("Remove", fit.Value.Text.Lines[0]); + Assert.StartsWith("backg", fit.Value.Text.Lines[1], StringComparison.Ordinal); + } + [AvaloniaFact] public void A_group_ring_follows_the_label_scale_as_far_as_its_slices_have_room() { @@ -374,13 +503,15 @@ SliceLabelInput Ring(string name, int k, LabelShow show) => // that itself now, in 0.5 px steps) until every slice shows its whole name; any ring ends with every slice // showing its name or icon. if (show == LabelShow.TextOnly) - Assert.All(layout.PlanRing(ring, 1.6, 1, false), l => Assert.True(l.NameWhole && l.Text!.Size < 13 * 1.6)); + Assert.All(layout.PlanRing(ring, 1.6, 1, false, ring: true), l => Assert.True(l.NameWhole && l.Text!.Size < 13 * 1.6)); var fitted = layout.PlanRingToFit(ring, 1.6, 1, false); Assert.All(fitted, l => Assert.True(l.Kind == SliceLabelKind.Icon || l.NameWhole, show.ToString())); + // Never smaller than at 100 %, and smaller than asked for, as the names don't fit whole at 160 %. var size = fitted.Select(l => l.Text?.Size ?? 0).Max(); - Assert.True(size is 0 or (> 13 and < 13 * 1.6), $"{show}: {size}"); + var atFull = layout.PlanRing(ring, 1, 1, false, ring: true).Select(l => l.Text?.Size ?? 0).Max(); + Assert.True(size == 0 || (size >= atFull && size < 13 * 1.6), $"{show}: {size} (at 100 %: {atFull})"); // At 100 % (or less) it is what PlanRing gives: no step below the scale asked for. - Assert.Equal(layout.PlanRing(ring, 0.8, 1, false).Select(l => (l.Kind, l.Text?.Size, l.IconSize)), + Assert.Equal(layout.PlanRing(ring, 0.8, 1, false, ring: true).Select(l => (l.Kind, l.Text?.Size, l.IconSize)), layout.PlanRingToFit(ring, 0.8, 1, false).Select(l => (l.Kind, l.Text?.Size, l.IconSize))); } // Room enough: the scale asked for. @@ -410,23 +541,22 @@ public void A_caption_is_dropped_before_the_name_is() } [AvaloniaFact] - public void A_name_beside_an_icon_keeps_most_of_its_letters_or_the_whole_ring_shows_icons() + public void A_name_beside_an_icon_keeps_most_of_its_letters_or_its_slice_shows_the_icon() { var layout = Layout(); foreach (var count in new[] { 6, 12 }) { var plan = layout.PlanRing(Enumerable.Range(0, count) .Select(i => Slice("Open in Terminal and more words", i, count, LabelShow.IconAndText, "console")).ToList(), 1, 1, false); - var kind = Assert.Single(plan.Select(l => l.Kind).Distinct()); - Assert.Contains(kind, new[] { SliceLabelKind.IconAndText, SliceLabelKind.Icon }); + Assert.All(plan, l => Assert.Contains(l.Kind, new[] { SliceLabelKind.IconAndText, SliceLabelKind.Icon })); foreach (var label in plan) if (label.Text is { Whole: false } text) - Assert.True(Kept(text) >= SliceLabelLayout.MinKept("Open in Terminal and more words"), text.Lines[0]); + Assert.True(KeepsMinKept(text, "Open in Terminal and more words"), string.Join(" / ", text.Lines)); Assert.Single(plan.Where(l => l.IconSize > 0).Select(l => l.IconSize).Distinct()); } } - // ───────────────────────── One style and size per ring, never a stub ───────────────────────── + // ──────────────── One size per ring, Auto one style, no stub beside an icon ──────────────── // The Images donut's back face, as shipped: every slice has an icon but AVIF (a format). private static readonly string[] Back = @@ -437,7 +567,22 @@ public void A_name_beside_an_icon_keeps_most_of_its_letters_or_the_whole_ring_sh private static List Face(string[] names, LabelShow show) => names.Select((n, i) => Slice(n, i, names.Length, show, n == "AVIF" ? null : "crop", n == "Rotate" ? "Group of 3" : n == "Remove background" ? "PNG" : "JPG")).ToList(); - private static int Kept(LabelText text) => new System.Globalization.StringInfo(text.Lines[0].TrimEnd('…').TrimEnd()).LengthInTextElements; + // The letters a shortened name keeps, on every line, against the letters MinKept asks of it (spaces not counted). + private static bool KeepsMinKept(LabelText text, string name) + { + static int Letters(string s) + { + var count = 0; + var e = System.Globalization.StringInfo.GetTextElementEnumerator(s); + while (e.MoveNext()) + if (!string.IsNullOrWhiteSpace(e.GetTextElement())) + count++; + return count; + } + var elements = new System.Globalization.StringInfo(name.Trim()); + var need = Letters(elements.SubstringByTextElements(0, Math.Min(elements.LengthInTextElements, SliceLabelLayout.MinKept(name)))); + return Letters(string.Concat(text.Lines).TrimEnd().TrimEnd('…')) >= need; + } [Fact] public void A_shortened_name_keeps_sixty_percent_of_its_letters_and_every_word_but_the_last() @@ -451,7 +596,7 @@ public void A_shortened_name_keeps_sixty_percent_of_its_letters_and_every_word_b [AvaloniaTheory] [InlineData(LabelShow.Auto)] [InlineData(LabelShow.IconAndText)] - public void No_ring_shows_a_stub_or_mixes_styles(LabelShow show) + public void No_name_beside_an_icon_is_a_stub_and_Auto_never_mixes(LabelShow show) { var layout = Layout(); foreach (var names in new[] { Back, Six }) @@ -460,44 +605,153 @@ public void No_ring_shows_a_stub_or_mixes_styles(LabelShow show) var slices = Face(names, show); var plan = layout.PlanRing(slices, labelScale, 1, showCaptions: true); var where = $"{names.Length} slices ×{labelScale} {show}"; - // The slices with an icon show one style; one without shows its name. - Assert.Single(plan.Where((_, i) => slices[i].IconKey is not null).Select(l => l.Kind).Distinct()); + // Auto: icons on all of the names or on none; Icon & text per slice. A slice without an icon shows its + // name. + if (show == LabelShow.Auto) + Assert.True(plan.Where((l, i) => slices[i].IconKey is not null && l.Kind != SliceLabelKind.Icon) + .Select(l => l.Kind).Distinct().Count() <= 1, where); Assert.All(plan.Where((_, i) => slices[i].IconKey is null), l => Assert.Equal(SliceLabelKind.Text, l.Kind)); + // Beside an icon, a name keeps MinKept; alone, Auto shortens it further where nothing else fits, rather + // than show its icon alone among names. for (var i = 0; i < plan.Count; i++) - if (plan[i].Text is { Whole: false } text) - { - Assert.True(show == LabelShow.IconAndText, $"{where}: Auto shortened \"{names[i]}\""); - Assert.True(Kept(text) >= SliceLabelLayout.MinKept(names[i]), $"{where}: \"{text.Lines[0]}\""); - } + if (plan[i] is { Kind: SliceLabelKind.IconAndText, Text: { Whole: false } text }) + Assert.True(KeepsMinKept(text, names[i]), $"{where}: \"{string.Join(" / ", text.Lines)}\""); } } [AvaloniaFact] - public void An_icon_stands_in_for_the_caption_and_names_alone_show_captions_on_all_or_none() + public void Captions_show_where_they_fit_beside_icons_too_and_never_make_the_names_smaller() { var layout = Layout(); - foreach (var show in new[] { LabelShow.Auto, LabelShow.IconAndText, LabelShow.TextOnly }) - foreach (var names in new[] { Back, Six }) - { - var plan = layout.PlanRing(Face(names, show), 1, 1, showCaptions: true); - Assert.All(plan.Where(l => l.Kind == SliceLabelKind.IconAndText), l => Assert.Null(l.Text!.Caption)); - // AVIF's caption is its own name: never shown. Every other name alone shows its caption, or none does. - var alone = plan.Where((l, i) => l.Kind == SliceLabelKind.Text && names[i] != "AVIF").ToList(); - var captions = alone.Count(l => l.Text!.Caption is not null); - Assert.True(captions == 0 || captions == alone.Count, $"{names.Length} {show}: {captions} of {alone.Count}"); - } - // Room for them: names alone with their captions. + // Room for them: names alone with their captions, and icon, name and caption at 80 % in Auto. var six = layout.PlanRing(Face(Six, LabelShow.TextOnly), 0.8, 1, showCaptions: true); Assert.All(six, l => Assert.NotNull(l.Text!.Caption)); + var icons = layout.PlanRing(Face(Six, LabelShow.Auto), 0.8, 1, showCaptions: true); + Assert.All(icons, l => Assert.Equal(SliceLabelKind.IconAndText, l.Kind)); + Assert.Contains(icons, l => l.Text!.Caption is not null); + // A caption that doesn't fit leaves its name alone, at the ring's size: an optional caption never makes the + // names smaller. + foreach (var show in new[] { LabelShow.Auto, LabelShow.IconAndText, LabelShow.TextOnly }) + { + var with = layout.PlanRing(Face(Back, show), 1, 1, showCaptions: true); + var without = layout.PlanRing(Face(Back, show), 1, 1, showCaptions: false); + Assert.Equal(without.Select(l => l.NameSize), with.Select(l => l.NameSize)); + } + } + + [Fact] + public void A_line_never_ends_on_an_opening_bracket_or_starts_on_a_closing_one() + { + var units = SliceLabelLayout.Units("フレームを抽出(PNG)").Select(u => u.Text).ToList(); + Assert.DoesNotContain(units, u => u.EndsWith('(')); + Assert.DoesNotContain(units, u => u.StartsWith(')') || u.StartsWith('ー')); + Assert.Equal("フレームを抽出(PNG)", string.Concat(units)); + Assert.Equal("PPTX(幻灯片)", string.Concat(SliceLabelLayout.Units("PPTX(幻灯片)").Select(u => u.Text))); + Assert.DoesNotContain(SliceLabelLayout.Units("PPTX(幻灯片)"), u => u.Text.EndsWith('(')); + } + + // The six-slice face with two slices of one name, told apart by their format, and a Copy to folder slice named + // after its folder, told apart from a Move by "Copy to folder" under it: the captions those three need. The others' + // format is shown only with Show output format on. + private static List Told(LabelShow show, string folder = "Projects") => + [ + Slice("Crop", 0, 6, show, "crop", "JPG"), + Slice("Rotate left", 1, 6, show, "rotate-left", "PDF") with { NeedsCaption = true }, + Slice("Rotate left", 2, 6, show, "rotate-left", "JPG") with { NeedsCaption = true }, + Slice("Grayscale", 3, 6, show, "crop", "JPG"), + Slice(folder, 4, 6, show, "content-copy", "Copy to folder") with { NeedsCaption = true }, + Slice("Compress image", 5, 6, show, "crop", "JPG"), + ]; + + [AvaloniaFact] + public void A_caption_a_slice_needs_shows_beside_its_icon_and_the_others_where_they_fit() + { + // Icon & text: the three that need their caption show it, on and off (beside the icon where it fits there, else + // under the name alone); with Show output format off, only those three. + var layout = Layout(); + foreach (var captions in new[] { true, false }) + { + var slices = Told(LabelShow.IconAndText); + var plan = layout.PlanRing(slices, 1, 1, captions); + Assert.All(plan.Where((_, i) => !slices[i].NeedsCaption), l => Assert.Equal(SliceLabelKind.IconAndText, l.Kind)); + for (var i = 0; i < plan.Count; i++) + if (slices[i].NeedsCaption) + Assert.Equal(slices[i].Caption, plan[i].Text!.Caption); + else if (!captions) + Assert.Null(plan[i].Text!.Caption); + } + } + + [AvaloniaFact] + public void Names_alone_show_the_captions_their_slices_need_with_Show_output_format_off() + { + var layout = Layout(); + foreach (var show in new[] { LabelShow.TextOnly, LabelShow.Auto }) + { + var slices = Told(show); + var off = layout.PlanRing(slices, 1, 1, showCaptions: false); + for (var i = 0; i < off.Count; i++) + Assert.Equal(slices[i].NeedsCaption ? slices[i].Caption : null, off[i].Text!.Caption); + // On: the needed ones still, the others where they fit. + var on = layout.PlanRing(slices, 1, 1, showCaptions: true); + Assert.All(on.Where((_, i) => slices[i].NeedsCaption), (l, i) => Assert.NotNull(l.Text!.Caption)); + } } [AvaloniaFact] - public void A_ring_steps_its_names_down_to_eleven_px_times_the_donut_size_and_never_under_ten() + public void Icon_only_shows_no_caption_even_one_a_slice_needs() { - Assert.Equal([13, 12.5, 12, 11.5, 11], SliceLabelLayout.SizesFor(13, 1)); - Assert.Equal(16.5, SliceLabelLayout.SizesFor(20.8, 1.5)[^1]); // 11 × 1.5 - Assert.Equal(10, SliceLabelLayout.SizesFor(13, 0.7)[^1]); // 11 × 0.7 = 7.7 → never under 10 - Assert.Equal([10.4], SliceLabelLayout.SizesFor(10.4, 1)); // already under the floor: as it is + var plan = Layout().PlanRing(Told(LabelShow.IconOnly), 1, 1, showCaptions: true); + Assert.All(plan, l => Assert.Equal(SliceLabelKind.Icon, l.Kind)); + Assert.All(plan, l => Assert.Null(l.Text)); + } + + [AvaloniaFact] + public void Slices_of_one_name_look_alike() + { + // Icon & text: where "Rotate left" (Same format) shows its name and caption without its icon, so does + // "Rotate left" (PDF); and a Copy and a Move slice for one folder alike. + var layout = Layout(); + List ring = + [ + Slice("Projects", 0, 6, LabelShow.IconAndText, "content-copy", "Copy to folder") with { NeedsCaption = true }, + Slice("Projects", 1, 6, LabelShow.IconAndText, "folder-move-outline", "Move to folder") with { NeedsCaption = true }, + Slice("Rotate left", 2, 6, LabelShow.IconAndText, "rotate-left", "Same format") with { NeedsCaption = true }, + Slice("Rotate left", 3, 6, LabelShow.IconAndText, "rotate-left", "PDF") with { NeedsCaption = true }, + Slice("Crop", 4, 6, LabelShow.IconAndText, "crop", "JPG"), + Slice("Compress image", 5, 6, LabelShow.IconAndText, "crop", "JPG"), + ]; + foreach (var captions in new[] { true, false }) + { + var plan = layout.PlanRing(ring, 1, 1, captions); + Assert.Equal(plan[0].Kind, plan[1].Kind); + Assert.Equal(plan[2].Kind, plan[3].Kind); + Assert.All(plan.Take(4), l => Assert.NotNull(l.Text!.Caption)); + } + } + + [AvaloniaFact] + public void A_needed_caption_with_no_room_anywhere_gives_way() + { + // A folder name so long that "Copy to folder" fits under it nowhere: Icon & text keeps the slice's icon (the + // name shortened beside it), and Auto shows the names, that one shortened, without it. + var layout = Layout(); + const string folder = "Holiday photos from the summer of 2026"; + var both = layout.PlanRing(Told(LabelShow.IconAndText, folder), 1, 1, showCaptions: false); + Assert.Contains(both[4].Kind, new[] { SliceLabelKind.IconAndText, SliceLabelKind.Icon }); + var auto = layout.PlanRing(Told(LabelShow.Auto, folder), 1, 1, showCaptions: false); + Assert.Equal(SliceLabelKind.Text, auto[4].Kind); + Assert.False(auto[4].NameWhole); + } + + [AvaloniaFact] + public void A_ring_steps_its_names_down_to_ten_px_times_the_donut_size_and_never_under_ten() + { + Assert.Equal([13, 12.5, 12, 11.5, 11, 10.5, 10], SliceLabelLayout.SizesFor(13, 1)); + Assert.Equal(15, SliceLabelLayout.SizesFor(20.8, 1.5)[^1]); // 10 × 1.5 + Assert.Equal(10, SliceLabelLayout.SizesFor(13, 0.7)[^1]); // 10 × 0.7 = 7 → never under 10 + Assert.Equal([10.4, 10], SliceLabelLayout.SizesFor(10.4, 1)); + Assert.Equal([10], SliceLabelLayout.SizesFor(10, 1)); // at the floor already: as it is var layout = Layout(); foreach (var show in new[] { LabelShow.Auto, LabelShow.IconAndText, LabelShow.TextOnly }) @@ -510,15 +764,15 @@ public void A_ring_steps_its_names_down_to_eleven_px_times_the_donut_size_and_ne }).ToList(); var plan = layout.PlanRing(slices, 1, scale, showCaptions: false); var size = Assert.Single(plan.Where(l => l.Text is not null).Select(l => l.Text!.Size).Distinct()); - Assert.InRange(size, Math.Max(10, 11 * scale) - 1e-9, 13 * scale + 1e-9); + Assert.InRange(size, Math.Max(10, 10 * scale) - 1e-9, 13 * scale + 1e-9); } } [AvaloniaFact] public void A_ring_runs_all_its_names_one_way() { - // A group's ring at the default donut, Group ring size 150 %: "Right" alone fits only along its slice; the ring's - // other names then run along theirs too, rather than straight beside it. + // A group's ring at the default donut, Group ring size 150 %: "Right" alone fits only along its slice; the + // ring's other names then run along theirs too, rather than straight beside it. var layout = Layout(); foreach (var (inner, depth) in new[] { (Outer + 13, 46.0), (Outer + 19.5, 69.0) }) { @@ -528,7 +782,7 @@ public void A_ring_runs_all_its_names_one_way() new SliceShape(Center, inner, outer, DonutGeometry.SubMidAngle(0.3, k, 3, span), span, Gap))).ToList(); foreach (var scale in new[] { 1.0, 1.5 }) { - var plan = layout.PlanRing(ring, 1, scale, false); + var plan = layout.PlanRing(ring, 1, scale, false, ring: true); var straight = plan.Select(l => Math.Abs(l.Text!.Rotation) < 1e-6).Distinct().ToList(); Assert.Single(straight); } @@ -544,25 +798,29 @@ public void A_ring_runs_all_its_names_one_way() } [AvaloniaFact] - public void Auto_shows_icon_and_name_on_the_six_slice_face_at_100_percent() + public void Auto_keeps_the_names_large_rather_than_shrink_them_for_icons() { - var plan = Layout().PlanRing(Face(Six, LabelShow.Auto), 1, 1, showCaptions: true); - Assert.All(plan, l => - { - Assert.Equal(SliceLabelKind.IconAndText, l.Kind); - Assert.True(l.NameWhole); - }); - Assert.InRange(plan[0].Text!.Size, 11, 13); // a step down from 13 px is allowed, 11 px at most + // The six-slice face at 120 %: "Remove background" has no room beside an icon at 15.6 px, so the ring shows its + // names alone at 15.6 px rather than smaller names with icons. At 100 % every name has room for its icon. + var layout = Layout(); + var large = layout.PlanRing(Face(Six, LabelShow.Auto), 1.2, 1, showCaptions: true); + Assert.All(large, l => Assert.True(l is { Kind: SliceLabelKind.Text, NameWhole: true })); + Assert.Equal(13 * 1.2, large[0].Text!.Size, 6); + var full = layout.PlanRing(Face(Six, LabelShow.Auto), 1, 1, showCaptions: true); + Assert.All(full, l => Assert.True(l is { Kind: SliceLabelKind.IconAndText, NameWhole: true })); + Assert.Equal(13, full[0].Text!.Size); } [AvaloniaFact] - public void Icon_and_text_on_the_twelve_slice_face_shows_icons_alone_rather_than_cut_names() + public void Icon_and_text_on_the_twelve_slice_face_keeps_an_icon_on_every_slice() { var slices = Face(Back, LabelShow.IconAndText); var plan = Layout().PlanRing(slices, 1, 1, showCaptions: true); for (var i = 0; i < plan.Count; i++) - Assert.Equal(slices[i].IconKey is null ? SliceLabelKind.Text : SliceLabelKind.Icon, plan[i].Kind); - Assert.True(plan[Array.IndexOf(Back, "AVIF")].NameWhole); + if (slices[i].IconKey is null) + Assert.True(plan[i] is { Kind: SliceLabelKind.Text, NameWhole: true }); + else + Assert.Contains(plan[i].Kind, new[] { SliceLabelKind.IconAndText, SliceLabelKind.Icon }); } [AvaloniaFact] diff --git a/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs b/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs index 4c403c22e..a9ea03afd 100644 --- a/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs +++ b/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs @@ -5,13 +5,14 @@ // Shown → a slice with a name or an icon shows something. // Never cut → a name is shortened with "…" only where the direction it runs in (its ring's, or its preset's) has // no room for it whole. -// Icon & text → a ring's slices asked for icon and name show icons alone only where one of them can't fit both, -// not even with its name shortened as far as allowed (SliceLabelLayout.MinKept): one style a ring. -// One style → the Auto slices of a ring all show the same style, and not a lesser one (icons alone, or names -// without their icons) where every one of them has room for more. +// Icon & text → a slice asked for icon and name shows its icon alone only where it can't fit both, not even with +// its name shortened as far as allowed (SliceLabelLayout.MinKept). +// One style → the Auto slices of a ring don't mix icons with names and names alone, and don't show a lesser +// style (icons alone, or names without their icons) where every one of them has room for more. // Bent → only names whose letters can be placed one by one bend along the ring. // One direction → the names of a ring whose presets leave Text direction on Auto all run one way: straight, out -// from the centre, bent along the ring, or level with it. +// from the centre, bent along the ring, or level with it. The donut's face keeps them upright, and +// so does a ring of Chinese, Japanese or Korean names. // // Layout only, nothing is drawn. "Room" is judged apart from the layout, from the slice's shape and the name's measured // size with a margin to spare (Room), so a gap in the layout shows up instead of being agreed with. @@ -102,19 +103,21 @@ private static void Sweep(string language, LabelDirection? direction, var (face, rings) = donut.PlanLabels(bold, regular); var where = $"{language} {profile.Id} {faceName} r{radius} hole {hole} ×{scale} {show} ring {ringSize}/{spread}" + (direction is { } d ? $" {d}" : ""); - Check(layout, face, donut.Items, show, where, problems); + Check(layout, face, donut.Items, show, where, problems, groupRing: false); for (var g = 0; g < rings.Count; g++) - Check(layout, rings[g], donut.Items[g].Children ?? [], show, $"{where} ring {donut.Items[g].Id}", problems); + Check(layout, rings[g], donut.Items[g].Children ?? [], show, $"{where} ring {donut.Items[g].Id}", problems, groupRing: true); } } } private static void Check(SliceLabelLayout layout, IReadOnlyList ring, IReadOnlyList items, - LabelShow show, string where, List problems) + LabelShow show, string where, List problems, bool groupRing) { + // A group's ring may turn or bend its names, unless they are Chinese, Japanese or Korean (those stay upright). + var turns = groupRing && !items.Any(i => (i.Direction ?? LabelDirection.Auto) == LabelDirection.Auto && + i.Label.Any(c => DonutMenu.IsHanOrKana(c) || c is >= '\uAC00' and <= '\uD7A3')); var autoKinds = new HashSet(); var (allRoomForName, allRoomForBoth) = (true, true); - var (bothAsked, iconsAlone, allRoomForShortened) = (0, 0, true); var ways = new HashSet { "straight", "out", "bent", "level" }; for (var i = 0; i < ring.Count; i++) { @@ -145,28 +148,32 @@ private static void Check(SliceLabelLayout layout, IReadOnlyList rin : Math.Abs(Math.IEEERemainder(cut.Rotation, 2 * Math.PI)) < 1e-6 ? LabelDirection.Straight : LabelDirection.AlongRing; var room = label.Kind == SliceLabelKind.IconAndText - ? Room(zone, icon + gap + line.Width, Math.Max(icon, line.Height), runs, bends) - : Room(zone, line.Width, line.Height, runs, bends); + ? Room(zone, icon + gap + line.Width, Math.Max(icon, line.Height), runs, bends, turns) + : Room(zone, line.Width, line.Height, runs, bends, turns); if (room is { } way) problems.Add($"{where}: \"{name}\" cut to \"{cut.Lines[0]}\" though it has room whole ({way})"); } if (wanted == LabelShow.IconAndText && item.IconKey is not null && name.Length > 0) { - bothAsked++; - iconsAlone += label.Kind == SliceLabelKind.Icon ? 1 : 0; var elements = new StringInfo(name.Trim()); var kept = SliceLabelLayout.MinKept(name); var shortest = kept >= elements.LengthInTextElements ? line : Measure(layout, elements.SubstringByTextElements(0, kept).TrimEnd() + "…", label.NameSize); - allRoomForShortened &= Room(zone, icon + gap + shortest.Width, Math.Max(icon, shortest.Height), direction, bends) is not null; + if (label.Kind == SliceLabelKind.Icon && + Room(zone, icon + gap + shortest.Width, Math.Max(icon, shortest.Height), direction, bends, turns) is not null) + problems.Add($"{where}: Icon & text shows \"{name}\" as its icon alone though it has room for icon and name"); } if (wanted == LabelShow.Auto && item.IconKey is not null && name.Length > 0) { autoKinds.Add(label.Kind); - allRoomForName &= Room(zone, line.Width, line.Height, direction, bends) is not null; - allRoomForBoth &= Room(zone, icon + gap + line.Width, Math.Max(icon, line.Height), direction, bends) is not null; + allRoomForName &= Room(zone, line.Width, line.Height, direction, bends, turns) is not null; + // Room for the icon beside the name and the caption it shows: Auto gives a slice its icon only where + // that costs it nothing. + var caption = label.Text?.Caption is { } shown ? Measure(layout, shown, captionSize) : default; + allRoomForBoth &= Room(zone, icon + gap + Math.Max(line.Width, caption.Width), Math.Max(icon, line.Height + caption.Height), + direction, bends, turns) is not null; } if (label.Text is { } run && direction == LabelDirection.Auto) @@ -184,11 +191,7 @@ private static void Check(SliceLabelLayout layout, IReadOnlyList rin } if (ways.Count == 0) problems.Add($"{where}: names run in different directions"); - if (bothAsked > 0 && iconsAlone == bothAsked && allRoomForShortened) - problems.Add($"{where}: Icon & text shows icons alone though every slice has room for icon and name"); - else if (iconsAlone > 0 && iconsAlone < bothAsked) - problems.Add($"{where}: Icon & text mixes icons alone with icons and names"); - if (autoKinds.Count > 1) + if (autoKinds.Contains(SliceLabelKind.Text) && autoKinds.Contains(SliceLabelKind.IconAndText)) problems.Add($"{where}: Auto slices mix {string.Join(", ", autoKinds)}"); else if (autoKinds.SetEquals([SliceLabelKind.Icon]) && allRoomForName) problems.Add($"{where}: Auto shows icons alone though every name has room"); @@ -204,16 +207,19 @@ private static Avalonia.Size Measure(SliceLabelLayout layout, string name, doubl return new Avalonia.Size(text.WidthIncludingTrailingWhitespace, text.Height); } - /// The share of a slice's room a label may take before it counts as plenty: the rest is kept to spare. + /// + /// The share of a slice's room a label may take before it counts as plenty: the rest is kept to spare. + /// private const double Plenty = 0.85; /// /// The way a block of by has plenty of room in - /// , among the directions allows, or null. Along the ring: its - /// height within the zone's depth, its width within the arc at its inner edge less the side margins. Out from the - /// centre: the same turned a quarter. Straight only on a slice at 12 or 6 o'clock, where it is the same as along. + /// , among the directions allows (on the donut's face, Auto + /// keeps names upright), or null. Along the ring: its height within the zone's depth, its width within the + /// arc at its inner edge less the side margins. Out from the centre: the same turned a quarter. Straight only on a + /// slice at 12 or 6 o'clock, where it is the same as along. /// - private static string? Room(SafeZone zone, double width, double height, LabelDirection direction, bool bends) + private static string? Room(SafeZone zone, double width, double height, LabelDirection direction, bool bends, bool groupRing) { bool AlongRing() { @@ -223,14 +229,16 @@ bool AlongRing() var arc = 2 * Math.Min(zone.HalfSpan, Math.PI) * inner - 2 * zone.Side; return width <= arc * Plenty; } - bool OutFromCentre() => width <= zone.Depth * Plenty && height <= (2 * Math.Min(zone.HalfSpan, Math.PI) * zone.Inner - 2 * zone.Side) * Plenty; + // Out from the centre the name lies on the middle line, where a group's chevron is: up to it. + bool OutFromCentre() => width <= (zone.OuterOnMiddle - zone.Inner) * Plenty && + height <= (2 * Math.Min(zone.HalfSpan, Math.PI) * zone.Inner - 2 * zone.Side) * Plenty; return direction switch { LabelDirection.AlongRing when bends => AlongRing() ? "along the ring" : null, LabelDirection.OutFromCentre => OutFromCentre() ? "out from the centre" : null, LabelDirection.Straight => null, - LabelDirection.Auto when bends && SliceLabelLayout.IsWide(zone) && AlongRing() => "along the ring", - LabelDirection.Auto when OutFromCentre() => "out from the centre", + LabelDirection.Auto when groupRing && bends && SliceLabelLayout.IsWide(zone) && AlongRing() => "along the ring", + LabelDirection.Auto when groupRing && OutFromCentre() => "out from the centre", _ => null, }; } diff --git a/tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs b/tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs index 8a2fc19d3..3c6c11fa2 100644 --- a/tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs +++ b/tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs @@ -1,5 +1,5 @@ -// The Toolbar page's slice-label settings: each donut keeps its own Label scale and Slice labels, and the page's preview -// donut shows them, with the slices' icons and their presets' own Show settings. +// The Toolbar page's slice-label settings: each donut keeps its own Label scale and Slice labels, and the page's +// preview donut shows them, with the slices' icons and their presets' own Show settings. using Avalonia.Headless.XUnit; using Filee.App.Services; @@ -78,7 +78,8 @@ public void The_preview_donut_shows_the_slices_icons_and_full_names() Assert.Equal("crop", item.IconKey); Assert.Equal(LabelShow.TextOnly, item.Show); Assert.Equal("Crop", item.FullName); - Assert.Null(vm.DonutItems.Single(i => i.Id == "to-avif").IconKey); + vm.EditingBack = false; + Assert.Null(vm.DonutItems.Single(i => i.Id == "to-png").IconKey); // a format conversion, on the front } finally { From 51785c72b1b41f9c4c4755144663d62d050d4800 Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Tue, 10 Nov 2026 13:28:06 -0600 Subject: [PATCH 5/6] i18n: short donut names where German and English were cut A long name gets a short form for the donut (preset..donut, as Half size and Pixelate already have) rather than being shrunk or cut by the layout. German gets one for every name a default donut cut (Freistellen, Kleiner machen, PDF / Videos / Audio verbinden, Bilder vereinen, Ohne Metadaten, Einzelbilder, Zuschneiden, Vektorbild, Ein ZIP), English two (Compress, Cut out). Every other language carries the same keys with its full name until a translator shortens it. The centre, the editors and the lists keep the full name. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Assets/i18n/ar.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/bn-BD.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/de.json | 14 +++++++++++++- src/Filee.App/Assets/i18n/en.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/es.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/fa.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/fr.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/hi.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/id.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/it.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/ja.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/ko.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/ne.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/pl.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/pt-BR.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/pt-PT.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/ru.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/th.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/tr.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/vi.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/zh-CN.json | 12 ++++++++++++ src/Filee.App/Assets/i18n/zh-TW.json | 12 ++++++++++++ 22 files changed, 265 insertions(+), 1 deletion(-) diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 543035d21..bd340cbf6 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -340,6 +340,7 @@ "hide.state_done": "تم", "preset.clip": "قص مقطع", "preset.group_crop_video": "اقتصاص إطار الفيديو", + "preset.group_crop_video.donut": "اقتصاص إطار الفيديو", "preset.crop_video_auto": "إزالة الأشرطة السوداء", "preset.crop_video_auto.short": "تلقائي", "preset.crop_video_16_9": "اقتصاص إلى 16:9", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "بوصة", "preset.grayscale": "تدرّج رمادي", "preset.remove_background": "إزالة الخلفية", + "preset.remove_background.donut": "إزالة الخلفية", "preset.trim_transparent": "قص الحواف الشفافة", "preset.group_rotate": "تدوير", "preset.rotate_left": "تدوير لليسار", @@ -444,6 +446,7 @@ "preset.flip_vertical": "انعكاس عمودي", "preset.flip_vertical.short": "عمودي", "preset.merge_pdf": "دمج PDF", + "preset.merge_pdf.donut": "دمج PDF", "merge.order_title": "اختيار ترتيب الدمج", "merge.order_hint": "استخدم الأسهم للتنقل بين البطاقات. لتغيير الترتيب، اسحب بطاقة أو استخدم Alt + ↑ / Alt + ↓. تُدمج الملفات من الأعلى إلى الأسفل. مفتاح Delete يحذف الملف المحدد.", "merge.sort_by": "ترتيب حسب", @@ -500,11 +503,13 @@ "pdf_editor.saved": "تم الحفظ: {0}", "preset.split_pdf": "تقسيم PDF", "preset.compress_pdf": "ضغط PDF", + "preset.compress_pdf.donut": "ضغط PDF", "preset.searchable_pdf": "PDF قابل للبحث", "preset.long_image": "صورة طويلة", "preset.pdf_slides": "PPTX (شرائح)", "preset.extract": "استخراج", "preset.zip_all": "ZIP واحد", + "preset.zip_all.donut": "ZIP واحد", "preset.extract_subtitles": "استخراج الترجمة", "preset.caption_video": "تسميات توضيحية", "preset.caption_video.donut": "تسميات", @@ -513,12 +518,15 @@ "preset.trace_line_art": "رسم خطي إلى SVG", "preset.trace_line_art.donut": "رسم خطي إلى SVG", "preset.extract_frames_png": "استخراج الإطارات (PNG)", + "preset.extract_frames_png.donut": "استخراج الإطارات (PNG)", "preset.extract_frames_jpg": "استخراج الإطارات (JPG)", "preset.extract_motion": "استخراج فيديو Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "ضغط الصورة", + "preset.compress_image.donut": "ضغط الصورة", "preset.compress_video": "ضغط الفيديو", + "preset.compress_video.donut": "ضغط الفيديو", "preset.for_email": "فيديو للبريد الإلكتروني (أقل من 18 ميغابايت)", "preset.for_discord": "فيديو لـ Discord (أقل من 10 ميغابايت)", "preset.for_whatsapp": "فيديو لـ WhatsApp (720p، أقل من 16 ميغابايت)", @@ -526,10 +534,14 @@ "preset.video_from_images": "فيديو من الصور", "preset.extract_audio": "استخراج الصوت", "preset.merge_videos": "دمج الفيديوهات", + "preset.merge_videos.donut": "دمج الفيديوهات", "preset.compress_audio": "ضغط الصوت", "preset.merge_audio": "دمج الصوت", + "preset.merge_audio.donut": "دمج الصوت", "preset.merge_images": "دمج الصور", + "preset.merge_images.donut": "دمج الصور", "preset.remove_metadata": "إزالة البيانات الوصفية", + "preset.remove_metadata.donut": "إزالة البيانات الوصفية", "format.folder": "مجلد", "format.category.Image": "صورة", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index db443bb20..f09023d31 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -339,6 +339,7 @@ "hide.state_done": "সম্পন্ন", "preset.clip": "একটি ক্লিপ কাটুন", "preset.group_crop_video": "ভিডিও ক্রপ করুন", + "preset.group_crop_video.donut": "ভিডিও ক্রপ করুন", "preset.crop_video_auto": "কালো বার সরান", "preset.crop_video_auto.short": "স্বয়ংক্রিয়", "preset.crop_video_16_9": "১৬:৯ অনুপাতে ক্রপ করুন", @@ -426,6 +427,7 @@ "image_edit.unit.Inches": "ইঞ্চি", "preset.grayscale": "গ্রেস্কেল", "preset.remove_background": "ব্যাকগ্রাউন্ড সরান", + "preset.remove_background.donut": "ব্যাকগ্রাউন্ড সরান", "preset.trim_transparent": "স্বচ্ছ বর্ডার ট্রিম করুন", "preset.group_rotate": "ঘোরান", "preset.rotate_left": "বামে ঘোরান", @@ -443,6 +445,7 @@ "preset.flip_vertical": "উল্লম্বভাবে উল্টান", "preset.flip_vertical.short": "উল্লম্ব", "preset.merge_pdf": "পিডিএফ একত্রিত করুন", + "preset.merge_pdf.donut": "পিডিএফ একত্রিত করুন", "merge.order_title": "একত্রিত করার ক্রম নির্বাচন করুন", "merge.order_hint": "কার্ডগুলোর মধ্যে চলাচলের জন্য তীর চিহ্ন ব্যবহার করুন। ক্রম পরিবর্তন করতে কোনো কার্ড টেনে আনুন বা Alt + ↑ / Alt + ↓ চাপুন। ফাইলগুলো ওপর থেকে নিচে একত্রিত হয়। Delete নির্বাচিত ফাইলটি সরিয়ে দেয়।", "merge.sort_by": "ক্রম অনুযায়ী সাজান", @@ -499,11 +502,13 @@ "pdf_editor.saved": "{0} সংরক্ষিত হয়েছে", "preset.split_pdf": "পিডিএফ বিভক্ত করুন", "preset.compress_pdf": "পিডিএফ সংকুচিত করুন", + "preset.compress_pdf.donut": "পিডিএফ সংকুচিত করুন", "preset.searchable_pdf": "অনুসন্ধানযোগ্য পিডিএফ", "preset.long_image": "লম্বা ছবি", "preset.pdf_slides": "PPTX (স্লাইড)", "preset.extract": "নিষ্কাশন করুন", "preset.zip_all": "একটি একক ZIP", + "preset.zip_all.donut": "একটি একক ZIP", "preset.extract_subtitles": "সাবটাইটেল নিষ্কাশন করুন", "preset.caption_video": "ক্যাপশন দিন", "preset.caption_video.donut": "ক্যাপশন", @@ -512,12 +517,15 @@ "preset.trace_line_art": "লাইন আর্ট থেকে SVG", "preset.trace_line_art.donut": "লাইন আর্ট", "preset.extract_frames_png": "ফ্রেম নিষ্কাশন (PNG)", + "preset.extract_frames_png.donut": "ফ্রেম নিষ্কাশন (PNG)", "preset.extract_frames_jpg": "ফ্রেম নিষ্কাশন (JPG)", "preset.extract_motion": "Live Photo ভিডিও নিষ্কাশন", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "ছবি সংকুচিত করুন", + "preset.compress_image.donut": "ছবি সংকুচিত করুন", "preset.compress_video": "ভিডিও সংকুচিত করুন", + "preset.compress_video.donut": "ভিডিও সংকুচিত করুন", "preset.for_email": "ই-মেইলের জন্য ভিডিও (১৮ এমবি-র কম)", "preset.for_discord": "Discord-এর জন্য ভিডিও (১০ এমবি-র কম)", "preset.for_whatsapp": "WhatsApp-এর জন্য ভিডিও (720p, ১৬ এমবি-র কম)", @@ -525,10 +533,14 @@ "preset.video_from_images": "ছবি থেকে ভিডিও", "preset.extract_audio": "অডিও নিষ্কাশন করুন", "preset.merge_videos": "ভিডিও একত্রিত করুন", + "preset.merge_videos.donut": "ভিডিও একত্রিত করুন", "preset.compress_audio": "অডিও সংকুচিত করুন", "preset.merge_audio": "অডিও একত্রিত করুন", + "preset.merge_audio.donut": "অডিও একত্রিত করুন", "preset.merge_images": "ছবি একত্রিত করুন", + "preset.merge_images.donut": "ছবি একত্রিত করুন", "preset.remove_metadata": "মেটাডাটা সরান", + "preset.remove_metadata.donut": "মেটাডাটা সরান", "format.folder": "ফোল্ডার", "format.category.Image": "ছবি", "format.category.Pdf": "পিডিএফ", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 47fe7abbd..e6867d123 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -340,6 +340,7 @@ "hide.state_done": "Fertig", "preset.clip": "Clip schneiden", "preset.group_crop_video": "Video zuschneiden", + "preset.group_crop_video.donut": "Zuschneiden", "preset.crop_video_auto": "Schwarze Balken entfernen", "preset.crop_video_auto.short": "Auto", "preset.crop_video_16_9": "Auf 16:9 zuschneiden", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "Zoll", "preset.grayscale": "Graustufen", "preset.remove_background": "Hintergrund entfernen", + "preset.remove_background.donut": "Freistellen", "preset.trim_transparent": "Transparente Ränder abschneiden", "preset.group_rotate": "Drehen", "preset.rotate_left": "Nach links drehen", @@ -444,6 +446,7 @@ "preset.flip_vertical": "Vertikal spiegeln", "preset.flip_vertical.short": "Vertikal", "preset.merge_pdf": "PDF zusammenführen", + "preset.merge_pdf.donut": "PDF verbinden", "merge.order_title": "Reihenfolge festlegen", "merge.order_hint": "Mit den Pfeiltasten navigieren. Zum Ändern der Reihenfolge ziehen oder Alt + ↑ / Alt + ↓ verwenden. Dateien werden von oben nach unten zusammengeführt. Entf entfernt die ausgewählte Datei.", "merge.sort_by": "Sortieren nach", @@ -500,25 +503,30 @@ "pdf_editor.saved": "{0} gespeichert", "preset.split_pdf": "PDF aufteilen", "preset.compress_pdf": "PDF komprimieren", + "preset.compress_pdf.donut": "Kleiner machen", "preset.searchable_pdf": "Durchsuchbare PDF", "preset.long_image": "Langes Bild", "preset.pdf_slides": "PPTX (Folien)", "preset.extract": "Entpacken", "preset.zip_all": "Eine ZIP-Datei", + "preset.zip_all.donut": "Ein ZIP", "preset.extract_subtitles": "Untertitel extrahieren", "preset.caption_video": "Untertitel", "preset.caption_video.donut": "Untertitel", "preset.trace_svg": "Vektor-SVG", - "preset.trace_svg.donut": "Vektorisieren", + "preset.trace_svg.donut": "Vektorbild", "preset.trace_line_art": "Linien-SVG", "preset.trace_line_art.donut": "Linien", "preset.extract_frames_png": "Einzelbilder (PNG)", + "preset.extract_frames_png.donut": "Einzelbilder", "preset.extract_frames_jpg": "Einzelbilder (JPG)", "preset.extract_motion": "Live-Photo-Video extrahieren", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Bild komprimieren", + "preset.compress_image.donut": "Kleiner machen", "preset.compress_video": "Video komprimieren", + "preset.compress_video.donut": "Kleiner machen", "preset.for_email": "Video für E-Mail (unter 18 MB)", "preset.for_discord": "Video für Discord (unter 10 MB)", "preset.for_whatsapp": "Video für WhatsApp (720p, unter 16 MB)", @@ -526,10 +534,14 @@ "preset.video_from_images": "Video aus Bildern", "preset.extract_audio": "Ton extrahieren", "preset.merge_videos": "Videos zusammenführen", + "preset.merge_videos.donut": "Videos verbinden", "preset.compress_audio": "Audio komprimieren", "preset.merge_audio": "Audio zusammenführen", + "preset.merge_audio.donut": "Audio verbinden", "preset.merge_images": "Bilder zusammenführen", + "preset.merge_images.donut": "Bilder vereinen", "preset.remove_metadata": "Metadaten entfernen", + "preset.remove_metadata.donut": "Ohne Metadaten", "format.folder": "Ordner", "format.category.Image": "Bild", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 2fc6c3180..3b61f5156 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -349,6 +349,7 @@ "hide.state_done": "Done", "preset.clip": "Cut a clip", "preset.group_crop_video": "Crop video", + "preset.group_crop_video.donut": "Crop video", "preset.crop_video_auto": "Remove black bars", "preset.crop_video_auto.short": "Auto", "preset.crop_video_16_9": "Crop to 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "inches", "preset.grayscale": "Grayscale", "preset.remove_background": "Remove background", + "preset.remove_background.donut": "Cut out", "preset.trim_transparent": "Trim transparent borders", "preset.group_rotate": "Rotate", "preset.rotate_left": "Rotate left", @@ -454,6 +456,7 @@ "preset.flip_vertical": "Flip vertical", "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Merge PDF", + "preset.merge_pdf.donut": "Merge PDF", "merge.order_title": "Choose merge order", "merge.order_hint": "Use the arrows to move between cards. To change the order, drag a card or press Alt + ↑ / Alt + ↓. Files merge from top to bottom. Delete removes the selected file.", "merge.sort_by": "Sort by", @@ -510,11 +513,13 @@ "pdf_editor.saved": "Saved {0}", "preset.split_pdf": "Split PDF", "preset.compress_pdf": "Compress PDF", + "preset.compress_pdf.donut": "Compress", "preset.searchable_pdf": "Searchable PDF", "preset.long_image": "Long image", "preset.pdf_slides": "PPTX (slides)", "preset.extract": "Extract", "preset.zip_all": "One ZIP", + "preset.zip_all.donut": "One ZIP", "preset.extract_subtitles": "Extract subtitles", "preset.caption_video": "Caption", "preset.caption_video.donut": "Caption", @@ -523,12 +528,15 @@ "preset.trace_line_art": "Line art to SVG", "preset.trace_line_art.donut": "Line art", "preset.extract_frames_png": "Extract frames (PNG)", + "preset.extract_frames_png.donut": "Extract frames (PNG)", "preset.extract_frames_jpg": "Extract frames (JPG)", "preset.extract_motion": "Extract Live Photo video", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Compress image", + "preset.compress_image.donut": "Compress image", "preset.compress_video": "Compress video", + "preset.compress_video.donut": "Compress video", "preset.for_email": "Video for email (under 18 MB)", "preset.for_discord": "Video for Discord (under 10 MB)", "preset.for_whatsapp": "Video for WhatsApp (720p, under 16 MB)", @@ -536,10 +544,14 @@ "preset.video_from_images": "Video from images", "preset.extract_audio": "Extract audio", "preset.merge_videos": "Merge videos", + "preset.merge_videos.donut": "Merge videos", "preset.compress_audio": "Compress audio", "preset.merge_audio": "Merge audio", + "preset.merge_audio.donut": "Merge audio", "preset.merge_images": "Merge images", + "preset.merge_images.donut": "Merge images", "preset.remove_metadata": "Remove metadata", + "preset.remove_metadata.donut": "Remove metadata", "format.folder": "Folder", "format.category.Image": "Image", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index b45357c87..22142d03e 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -340,6 +340,7 @@ "hide.state_done": "Hecho", "preset.clip": "Cortar un clip", "preset.group_crop_video": "Recortar vídeo", + "preset.group_crop_video.donut": "Recortar vídeo", "preset.crop_video_auto": "Quitar bandas negras", "preset.crop_video_auto.short": "Auto", "preset.crop_video_16_9": "Recortar a 16:9", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "pulgadas", "preset.grayscale": "Grises", "preset.remove_background": "Quitar fondo", + "preset.remove_background.donut": "Quitar fondo", "preset.trim_transparent": "Recortar bordes transparentes", "preset.group_rotate": "Rotar", "preset.rotate_left": "Girar izquierda", @@ -444,6 +446,7 @@ "preset.flip_vertical": "Volteo vertical", "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Combinar PDF", + "preset.merge_pdf.donut": "Combinar PDF", "merge.order_title": "Elegir el orden de combinación", "merge.order_hint": "Usa las flechas para navegar. Para cambiar el orden, arrastra o usa Alt + ↑ / Alt + ↓. Los archivos se combinan de arriba abajo. Supr quita el archivo seleccionado.", "merge.sort_by": "Ordenar por", @@ -500,11 +503,13 @@ "pdf_editor.saved": "Guardado: {0}", "preset.split_pdf": "Dividir PDF", "preset.compress_pdf": "Comprimir PDF", + "preset.compress_pdf.donut": "Comprimir PDF", "preset.searchable_pdf": "PDF con búsqueda", "preset.long_image": "Imagen larga", "preset.pdf_slides": "PPTX (diapositivas)", "preset.extract": "Extraer", "preset.zip_all": "Un solo ZIP", + "preset.zip_all.donut": "Un solo ZIP", "preset.extract_subtitles": "Extraer subtítulos", "preset.caption_video": "Subtítulos", "preset.caption_video.donut": "Subtítulos", @@ -513,12 +518,15 @@ "preset.trace_line_art": "Líneas a SVG", "preset.trace_line_art.donut": "Líneas a SVG", "preset.extract_frames_png": "Extraer fotogramas (PNG)", + "preset.extract_frames_png.donut": "Extraer fotogramas (PNG)", "preset.extract_frames_jpg": "Extraer fotogramas (JPG)", "preset.extract_motion": "Extraer vídeo de Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Comprimir imagen", + "preset.compress_image.donut": "Comprimir imagen", "preset.compress_video": "Comprimir vídeo", + "preset.compress_video.donut": "Comprimir vídeo", "preset.for_email": "Vídeo para correo (menos de 18 MB)", "preset.for_discord": "Vídeo para Discord (menos de 10 MB)", "preset.for_whatsapp": "Vídeo para WhatsApp (720p, menos de 16 MB)", @@ -526,10 +534,14 @@ "preset.video_from_images": "Vídeo de imágenes", "preset.extract_audio": "Extraer audio", "preset.merge_videos": "Unir vídeos", + "preset.merge_videos.donut": "Unir vídeos", "preset.compress_audio": "Comprimir audio", "preset.merge_audio": "Unir audio", + "preset.merge_audio.donut": "Unir audio", "preset.merge_images": "Unir imágenes", + "preset.merge_images.donut": "Unir imágenes", "preset.remove_metadata": "Quitar metadatos", + "preset.remove_metadata.donut": "Quitar metadatos", "format.folder": "Carpeta", "format.category.Image": "Imagen", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index b7018897d..9d6005e9e 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -339,6 +339,7 @@ "hide.state_done": "انجام شد", "preset.clip": "برش کلیپ", "preset.group_crop_video": "برش کادر ویدیو", + "preset.group_crop_video.donut": "برش کادر ویدیو", "preset.crop_video_auto": "حذف نوارهای سیاه", "preset.crop_video_auto.short": "خودکار", "preset.crop_video_16_9": "برش به ۱۶:۹", @@ -426,6 +427,7 @@ "image_edit.unit.Inches": "اینچ", "preset.grayscale": "سیاه و سفید (خاکستری)", "preset.remove_background": "حذف پس‌زمینه", + "preset.remove_background.donut": "حذف پس‌زمینه", "preset.trim_transparent": "حذف حاشیه‌های شفاف", "preset.group_rotate": "چرخش", "preset.rotate_left": "چرخش به چپ", @@ -443,6 +445,7 @@ "preset.flip_vertical": "وارونه عمودی", "preset.flip_vertical.short": "عمودی", "preset.merge_pdf": "ادغام PDF", + "preset.merge_pdf.donut": "ادغام PDF", "merge.order_title": "انتخاب ترتیب ادغام", "merge.order_hint": "از کلیدهای جهت‌نما برای جابه‌جایی بین کارت‌ها استفاده کنید. برای تغییر ترتیب، یک کارت را بکشید یا Alt + ↑ / Alt + ↓ را فشار دهید. فایل‌ها از بالا به پایین ادغام می‌شوند. دکمه Delete فایل انتخابی را حذف می‌کند.", "merge.sort_by": "مرتب‌سازی بر اساس", @@ -499,11 +502,13 @@ "pdf_editor.saved": "{0} ذخیره شد", "preset.split_pdf": "تقسیم PDF", "preset.compress_pdf": "فشرده‌سازی PDF", + "preset.compress_pdf.donut": "فشرده‌سازی PDF", "preset.searchable_pdf": "PDF قابل جستجو", "preset.long_image": "تصویر پیوسته", "preset.pdf_slides": "PPTX (اسلایدها)", "preset.extract": "استخراج", "preset.zip_all": "یک فایل ZIP", + "preset.zip_all.donut": "یک فایل ZIP", "preset.extract_subtitles": "استخراج زیرنویس‌ها", "preset.caption_video": "زیرنویس‌گذاری", "preset.caption_video.donut": "زیرنویس", @@ -512,12 +517,15 @@ "preset.trace_line_art": "طرح خطی به SVG", "preset.trace_line_art.donut": "طرح خطی", "preset.extract_frames_png": "استخراج فریم‌ها (PNG)", + "preset.extract_frames_png.donut": "استخراج فریم‌ها (PNG)", "preset.extract_frames_jpg": "استخراج فریم‌ها (JPG)", "preset.extract_motion": "استخراج ویدیوی Live Photo", "preset.live_photo_iphone": "Live Photo (آیفون)", "preset.motion_photo_android": "Motion Photo (اندروید)", "preset.compress_image": "فشرده‌سازی تصویر", + "preset.compress_image.donut": "فشرده‌سازی تصویر", "preset.compress_video": "فشرده‌سازی ویدیو", + "preset.compress_video.donut": "فشرده‌سازی ویدیو", "preset.for_email": "ویدیو برای ایمیل (زیر ۱۸ مگابایت)", "preset.for_discord": "ویدیو برای دیسکورد (زیر ۱۰ مگابایت)", "preset.for_whatsapp": "ویدیو برای واتس‌اپ (720p، زیر ۱۶ مگابایت)", @@ -525,10 +533,14 @@ "preset.video_from_images": "ویدیو از تصاویر", "preset.extract_audio": "استخراج صدا", "preset.merge_videos": "ادغام ویدیوها", + "preset.merge_videos.donut": "ادغام ویدیوها", "preset.compress_audio": "فشرده‌سازی صدا", "preset.merge_audio": "ادغام صداها", + "preset.merge_audio.donut": "ادغام صداها", "preset.merge_images": "ادغام تصاویر", + "preset.merge_images.donut": "ادغام تصاویر", "preset.remove_metadata": "حذف متادیتا", + "preset.remove_metadata.donut": "حذف متادیتا", "format.folder": "پوشه", "format.category.Image": "تصویر", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 7f10a3094..fdfbd4daf 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -349,6 +349,7 @@ "hide.state_done": "Terminé", "preset.clip": "Couper un extrait", "preset.group_crop_video": "Recadrer la vidéo", + "preset.group_crop_video.donut": "Recadrer la vidéo", "preset.crop_video_auto": "Supprimer les bandes noires", "preset.crop_video_auto.short": "Auto", "preset.crop_video_16_9": "Recadrer en 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "pouces", "preset.grayscale": "Niveaux de gris", "preset.remove_background": "Détourer", + "preset.remove_background.donut": "Détourer", "preset.trim_transparent": "Rogner les bords transparents", "preset.group_rotate": "Rotation", "preset.rotate_left": "Pivoter à gauche", @@ -454,6 +456,7 @@ "preset.flip_vertical": "Retourner verticalement", "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Fusionner les PDF", + "preset.merge_pdf.donut": "Fusionner les PDF", "merge.order_title": "Choisir l'ordre de fusion", "merge.order_hint": "Utilisez les flèches pour naviguer. Pour changer l'ordre, faites glisser ou utilisez Alt + ↑ / Alt + ↓. Les fichiers sont fusionnés de haut en bas. Suppr retire le fichier sélectionné.", "merge.sort_by": "Trier par", @@ -510,11 +513,13 @@ "pdf_editor.saved": "Enregistré : {0}", "preset.split_pdf": "Diviser le PDF", "preset.compress_pdf": "Compresser le PDF", + "preset.compress_pdf.donut": "Compresser le PDF", "preset.searchable_pdf": "PDF consultable", "preset.long_image": "Image longue", "preset.pdf_slides": "PPTX (diapos)", "preset.extract": "Extraire", "preset.zip_all": "Un seul ZIP", + "preset.zip_all.donut": "Un seul ZIP", "preset.extract_subtitles": "Extraire les sous-titres", "preset.caption_video": "Sous-titres", "preset.caption_video.donut": "Sous-titres", @@ -523,12 +528,15 @@ "preset.trace_line_art": "Trait en SVG", "preset.trace_line_art.donut": "Trait en SVG", "preset.extract_frames_png": "Extraire les images (PNG)", + "preset.extract_frames_png.donut": "Extraire les images (PNG)", "preset.extract_frames_jpg": "Extraire les images (JPG)", "preset.extract_motion": "Vidéo Live", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Compresser l'image", + "preset.compress_image.donut": "Compresser l'image", "preset.compress_video": "Compresser la vidéo", + "preset.compress_video.donut": "Compresser la vidéo", "preset.for_email": "Vidéo pour e-mail (moins de 18 Mo)", "preset.for_discord": "Vidéo pour Discord (moins de 10 Mo)", "preset.for_whatsapp": "Vidéo pour WhatsApp (720p, moins de 16 Mo)", @@ -536,10 +544,14 @@ "preset.video_from_images": "Vidéo à partir d'images", "preset.extract_audio": "Extraire l'audio", "preset.merge_videos": "Fusionner les vidéos", + "preset.merge_videos.donut": "Fusionner les vidéos", "preset.compress_audio": "Compresser l'audio", "preset.merge_audio": "Fusionner l'audio", + "preset.merge_audio.donut": "Fusionner l'audio", "preset.merge_images": "Fusionner les images", + "preset.merge_images.donut": "Fusionner les images", "preset.remove_metadata": "Supprimer les métadonnées", + "preset.remove_metadata.donut": "Supprimer les métadonnées", "format.folder": "Dossier", "format.category.Image": "Image", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index c16d262cb..7060e2005 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -340,6 +340,7 @@ "hide.state_done": "हो गया", "preset.clip": "क्लिप काटें", "preset.group_crop_video": "वीडियो क्रॉप करें", + "preset.group_crop_video.donut": "वीडियो क्रॉप करें", "preset.crop_video_auto": "काली पट्टियाँ हटाएँ", "preset.crop_video_auto.short": "ऑटो", "preset.crop_video_16_9": "16:9 में क्रॉप करें", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "इंच", "preset.grayscale": "ग्रेस्केल", "preset.remove_background": "बैकग्राउंड हटाएँ", + "preset.remove_background.donut": "बैकग्राउंड हटाएँ", "preset.trim_transparent": "पारदर्शी किनारे काटें", "preset.group_rotate": "घुमाएँ", "preset.rotate_left": "बाएँ घुमाएँ", @@ -444,6 +446,7 @@ "preset.flip_vertical": "खड़ा पलटें", "preset.flip_vertical.short": "खड़ा", "preset.merge_pdf": "PDF मर्ज करें", + "preset.merge_pdf.donut": "PDF मर्ज करें", "merge.order_title": "मर्ज करने का क्रम चुनें", "merge.order_hint": "कार्डों के बीच जाने के लिए तीर कुंजियाँ उपयोग करें। क्रम बदलने के लिए कार्ड खींचें या Alt + ↑ / Alt + ↓ दबाएं। फ़ाइलें ऊपर से नीचे जुड़ती हैं। Delete चुनी हुई फ़ाइल हटाता है।", "merge.sort_by": "क्रमबद्ध करें", @@ -500,11 +503,13 @@ "pdf_editor.saved": "सहेजा गया: {0}", "preset.split_pdf": "PDF बाँटें", "preset.compress_pdf": "PDF कंप्रेस करें", + "preset.compress_pdf.donut": "PDF कंप्रेस करें", "preset.searchable_pdf": "खोजने योग्य PDF", "preset.long_image": "लंबी इमेज", "preset.pdf_slides": "PPTX (स्लाइड)", "preset.extract": "निकालें", "preset.zip_all": "एक ZIP", + "preset.zip_all.donut": "एक ZIP", "preset.extract_subtitles": "सबटाइटल निकालें", "preset.caption_video": "उपशीर्षक", "preset.caption_video.donut": "उपशीर्षक", @@ -513,12 +518,15 @@ "preset.trace_line_art": "लाइन आर्ट को SVG में", "preset.trace_line_art.donut": "लाइन आर्ट को SVG में", "preset.extract_frames_png": "फ़्रेम निकालें (PNG)", + "preset.extract_frames_png.donut": "फ़्रेम निकालें (PNG)", "preset.extract_frames_jpg": "फ़्रेम निकालें (JPG)", "preset.extract_motion": "लाइव फ़ोटो वीडियो निकालें", "preset.live_photo_iphone": "लाइव फ़ोटो (iPhone)", "preset.motion_photo_android": "मोशन फ़ोटो (Android)", "preset.compress_image": "इमेज कंप्रेस करें", + "preset.compress_image.donut": "इमेज कंप्रेस करें", "preset.compress_video": "वीडियो कंप्रेस करें", + "preset.compress_video.donut": "वीडियो कंप्रेस करें", "preset.for_email": "ईमेल के लिए वीडियो (18 MB से कम)", "preset.for_discord": "Discord के लिए वीडियो (10 MB से कम)", "preset.for_whatsapp": "WhatsApp के लिए वीडियो (720p, 16 MB से कम)", @@ -526,10 +534,14 @@ "preset.video_from_images": "तस्वीरों से वीडियो", "preset.extract_audio": "ऑडियो निकालें", "preset.merge_videos": "वीडियो जोड़ें", + "preset.merge_videos.donut": "वीडियो जोड़ें", "preset.compress_audio": "ऑडियो कंप्रेस करें", "preset.merge_audio": "ऑडियो जोड़ें", + "preset.merge_audio.donut": "ऑडियो जोड़ें", "preset.merge_images": "तस्वीरें जोड़ें", + "preset.merge_images.donut": "तस्वीरें जोड़ें", "preset.remove_metadata": "मेटाडेटा हटाएँ", + "preset.remove_metadata.donut": "मेटाडेटा हटाएँ", "format.folder": "फ़ोल्डर", "format.category.Image": "इमेज", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index df3afb7b8..7a006d28e 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -340,6 +340,7 @@ "hide.state_done": "Selesai", "preset.clip": "Potong klip", "preset.group_crop_video": "Pangkas video", + "preset.group_crop_video.donut": "Pangkas video", "preset.crop_video_auto": "Hapus bilah hitam", "preset.crop_video_auto.short": "Otomatis", "preset.crop_video_16_9": "Pangkas ke 16:9", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "inci", "preset.grayscale": "Skala abu-abu", "preset.remove_background": "Hapus latar belakang", + "preset.remove_background.donut": "Hapus latar belakang", "preset.trim_transparent": "Pangkas batas transparan", "preset.group_rotate": "Putar", "preset.rotate_left": "Putar ke kiri", @@ -444,6 +446,7 @@ "preset.flip_vertical": "Balik vertikal", "preset.flip_vertical.short": "Vertikal", "preset.merge_pdf": "Gabungkan PDF", + "preset.merge_pdf.donut": "Gabungkan PDF", "merge.order_title": "Pilih urutan penggabungan", "merge.order_hint": "Gunakan panah untuk berpindah. Untuk mengubah urutan, seret kartu atau gunakan Alt + ↑ / Alt + ↓. File digabung dari atas ke bawah. Delete menghapus file yang dipilih.", "merge.sort_by": "Urutkan menurut", @@ -500,11 +503,13 @@ "pdf_editor.saved": "Disimpan {0}", "preset.split_pdf": "Pisahkan PDF", "preset.compress_pdf": "Kompres PDF", + "preset.compress_pdf.donut": "Kompres PDF", "preset.searchable_pdf": "PDF dapat dicari (OCR)", "preset.long_image": "Gambar panjang", "preset.pdf_slides": "PPTX (slide)", "preset.extract": "Ekstrak", "preset.zip_all": "Satu file ZIP", + "preset.zip_all.donut": "Satu file ZIP", "preset.extract_subtitles": "Ekstrak subtitle", "preset.caption_video": "Buat subtitle", "preset.caption_video.donut": "Subtitle", @@ -513,12 +518,15 @@ "preset.trace_line_art": "Gambar garis ke SVG", "preset.trace_line_art.donut": "Gambar garis", "preset.extract_frames_png": "Ekstrak bingkai (PNG)", + "preset.extract_frames_png.donut": "Ekstrak bingkai (PNG)", "preset.extract_frames_jpg": "Ekstrak bingkai (JPG)", "preset.extract_motion": "Ekstrak video Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Kompres gambar", + "preset.compress_image.donut": "Kompres gambar", "preset.compress_video": "Kompres video", + "preset.compress_video.donut": "Kompres video", "preset.for_email": "Video untuk email (di bawah 18 MB)", "preset.for_discord": "Video untuk Discord (di bawah 10 MB)", "preset.for_whatsapp": "Video untuk WhatsApp (720p, di bawah 16 MB)", @@ -526,10 +534,14 @@ "preset.video_from_images": "Video dari gambar", "preset.extract_audio": "Ekstrak audio", "preset.merge_videos": "Gabungkan video", + "preset.merge_videos.donut": "Gabungkan video", "preset.compress_audio": "Kompres audio", "preset.merge_audio": "Gabungkan audio", + "preset.merge_audio.donut": "Gabungkan audio", "preset.merge_images": "Gabungkan gambar", + "preset.merge_images.donut": "Gabungkan gambar", "preset.remove_metadata": "Hapus metadata", + "preset.remove_metadata.donut": "Hapus metadata", "format.folder": "Folder", "format.category.Image": "Gambar", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index f0ff337b9..5275c0126 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -339,6 +339,7 @@ "hide.state_done": "Fatto", "preset.clip": "Taglia una clip", "preset.group_crop_video": "Ritaglia video", + "preset.group_crop_video.donut": "Ritaglia video", "preset.crop_video_auto": "Rimuovi bande nere", "preset.crop_video_auto.short": "Auto", "preset.crop_video_16_9": "Ritaglia a 16:9", @@ -426,6 +427,7 @@ "image_edit.unit.Inches": "pollici", "preset.grayscale": "Scala di grigi", "preset.remove_background": "Rimuovi sfondo", + "preset.remove_background.donut": "Rimuovi sfondo", "preset.trim_transparent": "Ritaglia i bordi trasparenti", "preset.group_rotate": "Ruota", "preset.rotate_left": "Ruota a sinistra", @@ -443,6 +445,7 @@ "preset.flip_vertical": "Capovolgi verticalmente", "preset.flip_vertical.short": "Verticale", "preset.merge_pdf": "Unisci PDF", + "preset.merge_pdf.donut": "Unisci PDF", "merge.order_title": "Scegli l'ordine di unione", "merge.order_hint": "Usa le frecce per navigare. Per cambiare l'ordine, trascina oppure usa Alt + ↑ / Alt + ↓. I file vengono uniti dall'alto in basso. Canc rimuove il file selezionato.", "merge.sort_by": "Ordina per", @@ -499,11 +502,13 @@ "pdf_editor.saved": "Salvato {0}", "preset.split_pdf": "Dividi PDF", "preset.compress_pdf": "Comprimi PDF", + "preset.compress_pdf.donut": "Comprimi PDF", "preset.searchable_pdf": "PDF ricercabile", "preset.long_image": "Immagine lunga", "preset.pdf_slides": "PPTX (slide)", "preset.extract": "Estrai", "preset.zip_all": "Un unico ZIP", + "preset.zip_all.donut": "Un unico ZIP", "preset.extract_subtitles": "Estrai sottotitoli", "preset.caption_video": "Sottotitoli", "preset.caption_video.donut": "Sottotitoli", @@ -512,12 +517,15 @@ "preset.trace_line_art": "Disegno al tratto in SVG", "preset.trace_line_art.donut": "Al tratto", "preset.extract_frames_png": "Estrai fotogrammi (PNG)", + "preset.extract_frames_png.donut": "Estrai fotogrammi (PNG)", "preset.extract_frames_jpg": "Estrai fotogrammi (JPG)", "preset.extract_motion": "Estrai il video della Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Comprimi immagine", + "preset.compress_image.donut": "Comprimi immagine", "preset.compress_video": "Comprimi video", + "preset.compress_video.donut": "Comprimi video", "preset.for_email": "Video per e-mail (sotto i 18 MB)", "preset.for_discord": "Video per Discord (sotto i 10 MB)", "preset.for_whatsapp": "Video per WhatsApp (720p, sotto i 16 MB)", @@ -525,10 +533,14 @@ "preset.video_from_images": "Video da immagini", "preset.extract_audio": "Estrai audio", "preset.merge_videos": "Unisci video", + "preset.merge_videos.donut": "Unisci video", "preset.compress_audio": "Comprimi audio", "preset.merge_audio": "Unisci audio", + "preset.merge_audio.donut": "Unisci audio", "preset.merge_images": "Unisci immagini", + "preset.merge_images.donut": "Unisci immagini", "preset.remove_metadata": "Rimuovi metadati", + "preset.remove_metadata.donut": "Rimuovi metadati", "format.folder": "Cartella", "format.category.Image": "Immagine", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index fc028f6c0..d9147fb93 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -340,6 +340,7 @@ "hide.state_done": "完了", "preset.clip": "クリップを切り出す", "preset.group_crop_video": "動画のトリミング", + "preset.group_crop_video.donut": "動画のトリミング", "preset.crop_video_auto": "黒帯を削除", "preset.crop_video_auto.short": "自動", "preset.crop_video_16_9": "16:9 にトリミング", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "インチ", "preset.grayscale": "グレースケール", "preset.remove_background": "背景を削除", + "preset.remove_background.donut": "背景を削除", "preset.trim_transparent": "透明な余白を切り取る", "preset.group_rotate": "回転", "preset.rotate_left": "左に回転", @@ -444,6 +446,7 @@ "preset.flip_vertical": "垂直方向に反転", "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "結合", + "preset.merge_pdf.donut": "結合", "merge.order_title": "結合順序を選択", "merge.order_hint": "矢印キーでカード間を移動します。順番を変えるには、カードをドラッグするか Alt + ↑ / Alt + ↓ を押します。ファイルは上から下へ結合されます。Delete で選択中のファイルを削除します。", "merge.sort_by": "並べ替え", @@ -500,11 +503,13 @@ "pdf_editor.saved": "保存しました: {0}", "preset.split_pdf": "分割", "preset.compress_pdf": "圧縮", + "preset.compress_pdf.donut": "圧縮", "preset.searchable_pdf": "検索可能なPDF", "preset.long_image": "縦連結", "preset.pdf_slides": "PPTX (画像)", "preset.extract": "展開", "preset.zip_all": "1つに", + "preset.zip_all.donut": "1つに", "preset.extract_subtitles": "字幕抽出", "preset.caption_video": "字幕生成", "preset.caption_video.donut": "字幕", @@ -513,12 +518,15 @@ "preset.trace_line_art": "線画", "preset.trace_line_art.donut": "線画", "preset.extract_frames_png": "フレームを抽出(PNG)", + "preset.extract_frames_png.donut": "フレームを抽出(PNG)", "preset.extract_frames_jpg": "フレームを抽出(JPG)", "preset.extract_motion": "Live Photoの動画を抽出", "preset.live_photo_iphone": "Live Photo(iPhone)", "preset.motion_photo_android": "モーションフォト(Android)", "preset.compress_image": "画像を圧縮", + "preset.compress_image.donut": "画像を圧縮", "preset.compress_video": "動画を圧縮", + "preset.compress_video.donut": "動画を圧縮", "preset.for_email": "メール用の動画 (18 MB 未満)", "preset.for_discord": "Discord 用の動画 (10 MB 未満)", "preset.for_whatsapp": "WhatsApp 用の動画 (720p、16 MB 未満)", @@ -526,10 +534,14 @@ "preset.video_from_images": "写真から動画", "preset.extract_audio": "音声を抽出", "preset.merge_videos": "動画を結合", + "preset.merge_videos.donut": "動画を結合", "preset.compress_audio": "音声を圧縮", "preset.merge_audio": "音声を結合", + "preset.merge_audio.donut": "音声を結合", "preset.merge_images": "画像を結合", + "preset.merge_images.donut": "画像を結合", "preset.remove_metadata": "メタデータを削除", + "preset.remove_metadata.donut": "メタデータを削除", "format.folder": "フォルダー", "format.category.Image": "画像", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index e87b1e261..8e92d5ef1 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -349,6 +349,7 @@ "hide.state_done": "완료", "preset.clip": "클립 자르기", "preset.group_crop_video": "동영상 화면 자르기", + "preset.group_crop_video.donut": "동영상 화면 자르기", "preset.crop_video_auto": "검은 테두리 없애기", "preset.crop_video_auto.short": "자동", "preset.crop_video_16_9": "16:9로 자르기", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "인치", "preset.grayscale": "흑백", "preset.remove_background": "배경 제거", + "preset.remove_background.donut": "배경 제거", "preset.trim_transparent": "투명 여백 자르기", "preset.group_rotate": "회전", "preset.rotate_left": "왼쪽으로 회전", @@ -454,6 +456,7 @@ "preset.flip_vertical": "상하 반전", "preset.flip_vertical.short": "상하", "preset.merge_pdf": "PDF 병합", + "preset.merge_pdf.donut": "PDF 병합", "merge.order_title": "병합 순서 선택", "merge.order_hint": "화살표로 카드 사이를 옮겨 다녀요. 순서를 바꾸려면 카드를 끌거나 Alt + ↑ / Alt + ↓를 누르세요. 위에서부터 순서대로 병합돼요. Delete로 선택한 파일을 빼요.", "merge.sort_by": "정렬 기준", @@ -510,11 +513,13 @@ "pdf_editor.saved": "저장됨: {0}", "preset.split_pdf": "PDF 분할", "preset.compress_pdf": "PDF 압축", + "preset.compress_pdf.donut": "PDF 압축", "preset.searchable_pdf": "검색 가능한 PDF", "preset.long_image": "긴 이미지", "preset.pdf_slides": "PPTX (슬라이드)", "preset.extract": "압축 풀기", "preset.zip_all": "ZIP 하나로", + "preset.zip_all.donut": "ZIP 하나로", "preset.extract_subtitles": "자막 추출", "preset.caption_video": "자막 생성", "preset.caption_video.donut": "자막", @@ -523,12 +528,15 @@ "preset.trace_line_art": "흑백 선화", "preset.trace_line_art.donut": "흑백 선화", "preset.extract_frames_png": "프레임 추출 (PNG)", + "preset.extract_frames_png.donut": "프레임 추출 (PNG)", "preset.extract_frames_jpg": "프레임 추출 (JPG)", "preset.extract_motion": "라이브 포토 동영상 추출", "preset.live_photo_iphone": "라이브 포토 (아이폰)", "preset.motion_photo_android": "모션 포토 (안드로이드)", "preset.compress_image": "이미지 압축", + "preset.compress_image.donut": "이미지 압축", "preset.compress_video": "동영상 압축", + "preset.compress_video.donut": "동영상 압축", "preset.for_email": "이메일용 동영상 (18MB 미만)", "preset.for_discord": "Discord용 동영상 (10MB 미만)", "preset.for_whatsapp": "WhatsApp용 동영상 (720p, 16MB 미만)", @@ -536,10 +544,14 @@ "preset.video_from_images": "사진으로 동영상", "preset.extract_audio": "오디오 추출", "preset.merge_videos": "동영상 합치기", + "preset.merge_videos.donut": "동영상 합치기", "preset.compress_audio": "오디오 압축", "preset.merge_audio": "오디오 합치기", + "preset.merge_audio.donut": "오디오 합치기", "preset.merge_images": "이미지 합치기", + "preset.merge_images.donut": "이미지 합치기", "preset.remove_metadata": "메타데이터 제거", + "preset.remove_metadata.donut": "메타데이터 제거", "format.folder": "폴더", "format.category.Image": "이미지", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index fd9ebf514..1881e577b 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -340,6 +340,7 @@ "hide.state_done": "सम्पन्न", "preset.clip": "क्लिप काट्नुहोस्", "preset.group_crop_video": "भिडियो क्रप गर्नुहोस्", + "preset.group_crop_video.donut": "भिडियो क्रप गर्नुहोस्", "preset.crop_video_auto": "कालो पट्टी हटाउनुहोस्", "preset.crop_video_auto.short": "स्वचालित", "preset.crop_video_16_9": "16:9 मा क्रप गर्नुहोस्", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "इन्च", "preset.grayscale": "ग्रेस्केल", "preset.remove_background": "पृष्ठभूमि हटाउनुहोस्", + "preset.remove_background.donut": "पृष्ठभूमि हटाउनुहोस्", "preset.trim_transparent": "पारदर्शी किनारा काट्नुहोस्", "preset.group_rotate": "घुमाउनुहोस्", "preset.rotate_left": "बायाँ घुमाउनुहोस्", @@ -444,6 +446,7 @@ "preset.flip_vertical": "ठाडो उल्ट्याउनुहोस्", "preset.flip_vertical.short": "ठाडो", "preset.merge_pdf": "PDF मर्ज गर्नुहोस्", + "preset.merge_pdf.donut": "PDF मर्ज गर्नुहोस्", "merge.order_title": "मर्ज क्रम मिलाउनुहोस्", "merge.order_hint": "कार्डहरू बीच सार्न तीर कुञ्जी प्रयोग गर्नुहोस्। क्रम बदल्न कार्ड तान्नुहोस् वा Alt + ↑ / Alt + ↓ दबाउनुहोस्। फाइलहरू माथिबाट तलसम्म मर्ज हुन्छन्। Delete ले चयन गरिएको फाइल हटाउँछ।", "merge.sort_by": "क्रमबद्ध गर्नुहोस्", @@ -500,11 +503,13 @@ "pdf_editor.saved": "सेभ भयो: {0}", "preset.split_pdf": "PDF विभाजन गर्नुहोस्", "preset.compress_pdf": "PDF कम्प्रेस गर्नुहोस्", + "preset.compress_pdf.donut": "PDF कम्प्रेस गर्नुहोस्", "preset.searchable_pdf": "खोज्न मिल्ने PDF", "preset.long_image": "लामो छवि", "preset.pdf_slides": "PPTX (स्लाइडहरू)", "preset.extract": "निकाल्नुहोस्", "preset.zip_all": "एउटा ZIP", + "preset.zip_all.donut": "एउटा ZIP", "preset.extract_subtitles": "उपशीर्षकहरू निकाल्नुहोस्", "preset.caption_video": "उपशीर्षक", "preset.caption_video.donut": "उपशीर्षक", @@ -513,12 +518,15 @@ "preset.trace_line_art": "लाइन आर्टबाट SVG", "preset.trace_line_art.donut": "लाइन आर्टबाट SVG", "preset.extract_frames_png": "फ्रेमहरू निकाल्नुहोस् (PNG)", + "preset.extract_frames_png.donut": "फ्रेमहरू निकाल्नुहोस् (PNG)", "preset.extract_frames_jpg": "फ्रेमहरू निकाल्नुहोस् (JPG)", "preset.extract_motion": "लाइभ फोटो भिडियो निकाल्नुहोस्", "preset.live_photo_iphone": "लाइभ फोटो (iPhone)", "preset.motion_photo_android": "मोशन फोटो (Android)", "preset.compress_image": "छवि कम्प्रेस गर्नुहोस्", + "preset.compress_image.donut": "छवि कम्प्रेस गर्नुहोस्", "preset.compress_video": "भिडियो कम्प्रेस गर्नुहोस्", + "preset.compress_video.donut": "भिडियो कम्प्रेस गर्नुहोस्", "preset.for_email": "इमेलका लागि भिडियो (18 MB भन्दा कम)", "preset.for_discord": "Discord का लागि भिडियो (10 MB भन्दा कम)", "preset.for_whatsapp": "WhatsApp का लागि भिडियो (720p, 16 MB भन्दा कम)", @@ -526,10 +534,14 @@ "preset.video_from_images": "छविहरूबाट भिडियो", "preset.extract_audio": "अडियो निकाल्नुहोस्", "preset.merge_videos": "भिडियोहरू मर्ज गर्नुहोस्", + "preset.merge_videos.donut": "भिडियोहरू मर्ज गर्नुहोस्", "preset.compress_audio": "अडियो कम्प्रेस गर्नुहोस्", "preset.merge_audio": "अडियो मर्ज गर्नुहोस्", + "preset.merge_audio.donut": "अडियो मर्ज गर्नुहोस्", "preset.merge_images": "तस्बिरहरू मर्ज गर्नुहोस्", + "preset.merge_images.donut": "तस्बिरहरू मर्ज गर्नुहोस्", "preset.remove_metadata": "मेटाडाटा हटाउनुहोस्", + "preset.remove_metadata.donut": "मेटाडाटा हटाउनुहोस्", "format.folder": "फोल्डर", "format.category.Image": "छवि", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 5f4a2c2db..7871fd817 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -349,6 +349,7 @@ "hide.state_done": "Gotowe", "preset.clip": "Wytnij klip", "preset.group_crop_video": "Kadruj wideo", + "preset.group_crop_video.donut": "Kadruj wideo", "preset.crop_video_auto": "Usuń czarne pasy", "preset.crop_video_auto.short": "Auto", "preset.crop_video_16_9": "Kadruj do 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "cale", "preset.grayscale": "Skala szarości", "preset.remove_background": "Usuń tło", + "preset.remove_background.donut": "Usuń tło", "preset.trim_transparent": "Przytnij przezroczyste brzegi", "preset.group_rotate": "Obróć", "preset.rotate_left": "Obróć w lewo", @@ -454,6 +456,7 @@ "preset.flip_vertical": "Odbij w pionie", "preset.flip_vertical.short": "Pionowo", "preset.merge_pdf": "Połącz PDF", + "preset.merge_pdf.donut": "Połącz PDF", "merge.order_title": "Wybierz kolejność scalania", "merge.order_hint": "Strzałkami przechodź między kartami. Aby zmienić kolejność, przeciągnij kartę lub użyj Alt + ↑ / Alt + ↓. Pliki są łączone od góry do dołu. Delete usuwa zaznaczony plik.", "merge.sort_by": "Sortuj według", @@ -510,11 +513,13 @@ "pdf_editor.saved": "Zapisano: {0}", "preset.split_pdf": "Podziel PDF", "preset.compress_pdf": "Kompresuj PDF", + "preset.compress_pdf.donut": "Kompresuj PDF", "preset.searchable_pdf": "Przeszukiwalny PDF", "preset.long_image": "Długi obraz", "preset.pdf_slides": "PPTX (slajdy)", "preset.extract": "Rozpakuj", "preset.zip_all": "Jeden ZIP", + "preset.zip_all.donut": "Jeden ZIP", "preset.extract_subtitles": "Wyodrębnij napisy", "preset.caption_video": "Napisy", "preset.caption_video.donut": "Napisy", @@ -523,12 +528,15 @@ "preset.trace_line_art": "Rysunek do SVG", "preset.trace_line_art.donut": "Rysunek do SVG", "preset.extract_frames_png": "Wyodrębnij klatki (PNG)", + "preset.extract_frames_png.donut": "Wyodrębnij klatki (PNG)", "preset.extract_frames_jpg": "Wyodrębnij klatki (JPG)", "preset.extract_motion": "Wyodrębnij wideo z Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Kompresuj obraz", + "preset.compress_image.donut": "Kompresuj obraz", "preset.compress_video": "Kompresuj wideo", + "preset.compress_video.donut": "Kompresuj wideo", "preset.for_email": "Wideo do e-maila (poniżej 18 MB)", "preset.for_discord": "Wideo na Discorda (poniżej 10 MB)", "preset.for_whatsapp": "Wideo na WhatsAppa (720p, poniżej 16 MB)", @@ -536,10 +544,14 @@ "preset.video_from_images": "Wideo ze zdjęć", "preset.extract_audio": "Wyodrębnij dźwięk", "preset.merge_videos": "Połącz wideo", + "preset.merge_videos.donut": "Połącz wideo", "preset.compress_audio": "Kompresuj dźwięk", "preset.merge_audio": "Połącz dźwięk", + "preset.merge_audio.donut": "Połącz dźwięk", "preset.merge_images": "Połącz obrazy", + "preset.merge_images.donut": "Połącz obrazy", "preset.remove_metadata": "Usuń metadane", + "preset.remove_metadata.donut": "Usuń metadane", "format.folder": "Folder", "format.category.Image": "Obraz", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 7f2bacd60..dea73a4d4 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -296,6 +296,7 @@ "preset.crop": "Recortar", "preset.clip": "Cortar um clipe", "preset.group_crop_video": "Recortar vídeo", + "preset.group_crop_video.donut": "Recortar vídeo", "preset.crop_video_auto": "Remover barras pretas", "preset.crop_video_auto.short": "Auto", "preset.crop_video_16_9": "Recortar em 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "polegadas", "preset.grayscale": "Tons de cinza", "preset.remove_background": "Remover fundo", + "preset.remove_background.donut": "Remover fundo", "preset.trim_transparent": "Cortar bordas transparentes", "preset.group_rotate": "Girar", "preset.rotate_left": "Girar à esquerda", @@ -454,6 +456,7 @@ "preset.flip_vertical": "Espelhar na vertical", "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Mesclar PDF", + "preset.merge_pdf.donut": "Mesclar PDF", "merge.order_title": "Escolha a ordem da mesclagem", "merge.order_hint": "Use as setas para navegar. Para mover a ordem, clique e arraste ou use Alt + ↑ / Alt + ↓. Os arquivos são mesclados de cima para baixo. Delete remove o arquivo selecionado.", "merge.sort_by": "Ordenar por", @@ -510,11 +513,13 @@ "pdf_editor.saved": "Salvo: {0}", "preset.split_pdf": "Dividir PDF", "preset.compress_pdf": "Comprimir PDF", + "preset.compress_pdf.donut": "Comprimir PDF", "preset.searchable_pdf": "PDF pesquisável", "preset.long_image": "Imagem longa", "preset.pdf_slides": "PPTX (slides)", "preset.extract": "Extrair", "preset.zip_all": "Um ZIP", + "preset.zip_all.donut": "Um ZIP", "preset.extract_subtitles": "Extrair legendas", "preset.caption_video": "Legendas", "preset.caption_video.donut": "Legendas", @@ -523,12 +528,15 @@ "preset.trace_line_art": "Traço em SVG", "preset.trace_line_art.donut": "Traços", "preset.extract_frames_png": "Extrair quadros (PNG)", + "preset.extract_frames_png.donut": "Extrair quadros (PNG)", "preset.extract_frames_jpg": "Extrair quadros (JPG)", "preset.extract_motion": "Extrair vídeo da Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Comprimir imagem", + "preset.compress_image.donut": "Comprimir imagem", "preset.compress_video": "Comprimir vídeo", + "preset.compress_video.donut": "Comprimir vídeo", "preset.for_email": "Vídeo para e-mail (menos de 18 MB)", "preset.for_discord": "Vídeo para Discord (menos de 10 MB)", "preset.for_whatsapp": "Vídeo para WhatsApp (720p, menos de 16 MB)", @@ -536,10 +544,14 @@ "preset.video_from_images": "Vídeo de imagens", "preset.extract_audio": "Extrair áudio", "preset.merge_videos": "Juntar vídeos", + "preset.merge_videos.donut": "Juntar vídeos", "preset.compress_audio": "Comprimir áudio", "preset.merge_audio": "Juntar áudio", + "preset.merge_audio.donut": "Juntar áudio", "preset.merge_images": "Juntar imagens", + "preset.merge_images.donut": "Juntar imagens", "preset.remove_metadata": "Remover metadados", + "preset.remove_metadata.donut": "Remover metadados", "format.folder": "Pasta", "format.category.Image": "Imagem", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index b04d690f4..868e0c6e6 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -349,6 +349,7 @@ "hide.state_done": "Concluído", "preset.clip": "Cortar um excerto", "preset.group_crop_video": "Recortar vídeo", + "preset.group_crop_video.donut": "Recortar vídeo", "preset.crop_video_auto": "Remover barras pretas", "preset.crop_video_auto.short": "Auto", "preset.crop_video_16_9": "Recortar para 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "polegadas", "preset.grayscale": "Escala de cinzentos", "preset.remove_background": "Remover fundo", + "preset.remove_background.donut": "Remover fundo", "preset.trim_transparent": "Recortar margens transparentes", "preset.group_rotate": "Rodar", "preset.rotate_left": "Rodar para a esquerda", @@ -454,6 +456,7 @@ "preset.flip_vertical": "Inverter na vertical", "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Juntar PDF", + "preset.merge_pdf.donut": "Juntar PDF", "merge.order_title": "Escolher a ordem dos ficheiros", "merge.order_hint": "Use as setas para navegar. Para mudar a ordem, clique e arraste ou use Alt + ↑ / Alt + ↓. Os ficheiros são unidos de cima para baixo. Delete remove o ficheiro selecionado.", "merge.sort_by": "Ordenar por", @@ -510,11 +513,13 @@ "pdf_editor.saved": "Guardado: {0}", "preset.split_pdf": "Dividir PDF", "preset.compress_pdf": "Comprimir PDF", + "preset.compress_pdf.donut": "Comprimir PDF", "preset.searchable_pdf": "PDF pesquisável", "preset.long_image": "Imagem longa", "preset.pdf_slides": "PPTX (diapositivos)", "preset.extract": "Extrair", "preset.zip_all": "Um só ZIP", + "preset.zip_all.donut": "Um só ZIP", "preset.extract_subtitles": "Extrair legendas", "preset.caption_video": "Legendas", "preset.caption_video.donut": "Legendas", @@ -523,12 +528,15 @@ "preset.trace_line_art": "Traços para SVG", "preset.trace_line_art.donut": "Traços", "preset.extract_frames_png": "Extrair fotogramas (PNG)", + "preset.extract_frames_png.donut": "Extrair fotogramas (PNG)", "preset.extract_frames_jpg": "Extrair fotogramas (JPG)", "preset.extract_motion": "Extrair vídeo da Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Comprimir imagem", + "preset.compress_image.donut": "Comprimir imagem", "preset.compress_video": "Comprimir vídeo", + "preset.compress_video.donut": "Comprimir vídeo", "preset.for_email": "Vídeo para e-mail (menos de 18 MB)", "preset.for_discord": "Vídeo para o Discord (menos de 10 MB)", "preset.for_whatsapp": "Vídeo para o WhatsApp (720p, menos de 16 MB)", @@ -536,10 +544,14 @@ "preset.video_from_images": "Vídeo a partir de imagens", "preset.extract_audio": "Extrair áudio", "preset.merge_videos": "Juntar vídeos", + "preset.merge_videos.donut": "Juntar vídeos", "preset.compress_audio": "Comprimir áudio", "preset.merge_audio": "Juntar áudio", + "preset.merge_audio.donut": "Juntar áudio", "preset.merge_images": "Juntar imagens", + "preset.merge_images.donut": "Juntar imagens", "preset.remove_metadata": "Remover metadados", + "preset.remove_metadata.donut": "Remover metadados", "format.folder": "Pasta", "format.category.Image": "Imagem", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 9f5c49978..256a7f645 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -349,6 +349,7 @@ "hide.state_done": "Готово", "preset.clip": "Вырезать клип", "preset.group_crop_video": "Обрезать кадр видео", + "preset.group_crop_video.donut": "Обрезать кадр видео", "preset.crop_video_auto": "Убрать чёрные полосы", "preset.crop_video_auto.short": "Авто", "preset.crop_video_16_9": "Обрезать до 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "дюймы", "preset.grayscale": "Оттенки серого", "preset.remove_background": "Убрать фон", + "preset.remove_background.donut": "Убрать фон", "preset.trim_transparent": "Обрезать прозрачные края", "preset.group_rotate": "Повернуть", "preset.rotate_left": "Повернуть влево", @@ -454,6 +456,7 @@ "preset.flip_vertical": "Отразить по вертикали", "preset.flip_vertical.short": "По верт.", "preset.merge_pdf": "Объединить PDF", + "preset.merge_pdf.donut": "Объединить PDF", "merge.order_title": "Выберите порядок объединения", "merge.order_hint": "Стрелками переходите между карточками. Чтобы изменить порядок, перетащите карточку или нажмите Alt + ↑ / Alt + ↓. Файлы объединяются сверху вниз. Delete удаляет выбранный файл.", "merge.sort_by": "Сортировка", @@ -510,11 +513,13 @@ "pdf_editor.saved": "Сохранено: {0}", "preset.split_pdf": "Разделить PDF", "preset.compress_pdf": "Сжать PDF", + "preset.compress_pdf.donut": "Сжать PDF", "preset.searchable_pdf": "PDF с поиском", "preset.long_image": "Длинная картинка", "preset.pdf_slides": "PPTX (слайды)", "preset.extract": "Распаковать", "preset.zip_all": "Один ZIP", + "preset.zip_all.donut": "Один ZIP", "preset.extract_subtitles": "Извлечь субтитры", "preset.caption_video": "Субтитры", "preset.caption_video.donut": "Субтитры", @@ -523,12 +528,15 @@ "preset.trace_line_art": "Линии в SVG", "preset.trace_line_art.donut": "Линии в SVG", "preset.extract_frames_png": "Кадры в картинки (PNG)", + "preset.extract_frames_png.donut": "Кадры в картинки (PNG)", "preset.extract_frames_jpg": "Кадры в картинки (JPG)", "preset.extract_motion": "Видео из Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Сжать изображение", + "preset.compress_image.donut": "Сжать изображение", "preset.compress_video": "Сжать видео", + "preset.compress_video.donut": "Сжать видео", "preset.for_email": "Видео для почты (до 18 МБ)", "preset.for_discord": "Видео для Discord (до 10 МБ)", "preset.for_whatsapp": "Видео для WhatsApp (720p, до 16 МБ)", @@ -536,10 +544,14 @@ "preset.video_from_images": "Видео из фото", "preset.extract_audio": "Извлечь звук", "preset.merge_videos": "Объединить видео", + "preset.merge_videos.donut": "Объединить видео", "preset.compress_audio": "Сжать аудио", "preset.merge_audio": "Объединить аудио", + "preset.merge_audio.donut": "Объединить аудио", "preset.merge_images": "Объединить изображения", + "preset.merge_images.donut": "Объединить изображения", "preset.remove_metadata": "Удалить метаданные", + "preset.remove_metadata.donut": "Удалить метаданные", "format.folder": "Папка", "format.category.Image": "Изображение", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 429f23e44..b9efccce2 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -340,6 +340,7 @@ "hide.state_done": "เสร็จแล้ว", "preset.clip": "ตัดคลิป", "preset.group_crop_video": "ครอบตัดวิดีโอ", + "preset.group_crop_video.donut": "ครอบตัดวิดีโอ", "preset.crop_video_auto": "ลบแถบดำ", "preset.crop_video_auto.short": "อัตโนมัติ", "preset.crop_video_16_9": "ครอบตัดเป็น 16:9", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "นิ้ว", "preset.grayscale": "ขาวดำ (เกรย์สเกล)", "preset.remove_background": "ลบพื้นหลัง", + "preset.remove_background.donut": "ลบพื้นหลัง", "preset.trim_transparent": "ตัดขอบโปร่งใส", "preset.group_rotate": "หมุนภาพ", "preset.rotate_left": "หมุนซ้าย", @@ -444,6 +446,7 @@ "preset.flip_vertical": "พลิกแนวตั้ง", "preset.flip_vertical.short": "แนวตั้ง", "preset.merge_pdf": "รวมไฟล์ PDF", + "preset.merge_pdf.donut": "รวมไฟล์ PDF", "merge.order_title": "เลือกลำดับการรวมไฟล์", "merge.order_hint": "ใช้ปุ่มลูกศรเพื่อไปยังการ์ดต่าง ๆ เปลี่ยนลำดับโดยลากการ์ดหรือกด Alt + ↑ / Alt + ↓ ไฟล์จะถูกรวมจากบนลงล่าง กด Delete เพื่อนำไฟล์ที่เลือกออก", "merge.sort_by": "เรียงตาม", @@ -500,11 +503,13 @@ "pdf_editor.saved": "บันทึกแล้ว {0}", "preset.split_pdf": "แยกหน้า PDF", "preset.compress_pdf": "บีบอัด PDF", + "preset.compress_pdf.donut": "บีบอัด PDF", "preset.searchable_pdf": "PDF ที่ค้นหาได้", "preset.long_image": "รูปภาพขนาดยาว", "preset.pdf_slides": "PPTX (สไลด์)", "preset.extract": "แตกไฟล์", "preset.zip_all": "รวมเป็น ZIP เดียว", + "preset.zip_all.donut": "รวมเป็น ZIP เดียว", "preset.extract_subtitles": "แยกคำบรรยาย", "preset.caption_video": "คำบรรยาย", "preset.caption_video.donut": "คำบรรยาย", @@ -513,12 +518,15 @@ "preset.trace_line_art": "แปลงลายเส้นเป็น SVG", "preset.trace_line_art.donut": "แปลงลายเส้นเป็น SVG", "preset.extract_frames_png": "ดึงเฟรมภาพ (PNG)", + "preset.extract_frames_png.donut": "ดึงเฟรมภาพ (PNG)", "preset.extract_frames_jpg": "ดึงเฟรมภาพ (JPG)", "preset.extract_motion": "ดึงวิดีโอจาก Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "บีบอัดรูปภาพ", + "preset.compress_image.donut": "บีบอัดรูปภาพ", "preset.compress_video": "บีบอัดวิดีโอ", + "preset.compress_video.donut": "บีบอัดวิดีโอ", "preset.for_email": "วิดีโอสำหรับอีเมล (ไม่เกิน 18 MB)", "preset.for_discord": "วิดีโอสำหรับ Discord (ไม่เกิน 10 MB)", "preset.for_whatsapp": "วิดีโอสำหรับ WhatsApp (720p ไม่เกิน 16 MB)", @@ -526,10 +534,14 @@ "preset.video_from_images": "วิดีโอจากรูปภาพ", "preset.extract_audio": "แยกเสียง", "preset.merge_videos": "รวมวิดีโอ", + "preset.merge_videos.donut": "รวมวิดีโอ", "preset.compress_audio": "บีบอัดเสียง", "preset.merge_audio": "รวมไฟล์เสียง", + "preset.merge_audio.donut": "รวมไฟล์เสียง", "preset.merge_images": "รวมรูปภาพ", + "preset.merge_images.donut": "รวมรูปภาพ", "preset.remove_metadata": "ลบเมทาดาตา", + "preset.remove_metadata.donut": "ลบเมทาดาตา", "format.folder": "โฟลเดอร์", "format.category.Image": "รูปภาพ", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 2133d4278..12fa5a892 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -340,6 +340,7 @@ "hide.state_done": "Tamamlandı", "preset.clip": "Klip kes", "preset.group_crop_video": "Videoyu kırp", + "preset.group_crop_video.donut": "Videoyu kırp", "preset.crop_video_auto": "Siyah bantları kaldır", "preset.crop_video_auto.short": "Otomatik", "preset.crop_video_16_9": "16:9 olarak kırp", @@ -427,6 +428,7 @@ "image_edit.unit.Inches": "inç", "preset.grayscale": "Gri tonlama", "preset.remove_background": "Arka planı kaldır", + "preset.remove_background.donut": "Arka planı kaldır", "preset.trim_transparent": "Saydam kenarları kırp", "preset.group_rotate": "Döndür", "preset.rotate_left": "Sola döndür", @@ -444,6 +446,7 @@ "preset.flip_vertical": "Dikey çevir", "preset.flip_vertical.short": "Dikey", "preset.merge_pdf": "PDF birleştir", + "preset.merge_pdf.donut": "PDF birleştir", "merge.order_title": "Birleştirme sırasını seçin", "merge.order_hint": "Kartlar arasında gezinmek için ok tuşlarını kullanın. Sırayı değiştirmek için bir kartı sürükleyin veya Alt + ↑ / Alt + ↓ kullanın. Dosyalar yukarıdan aşağıya birleştirilir. Delete seçili dosyayı kaldırır.", "merge.sort_by": "Sıralama", @@ -500,11 +503,13 @@ "pdf_editor.saved": "Kaydedildi: {0}", "preset.split_pdf": "PDF böl", "preset.compress_pdf": "PDF sıkıştır", + "preset.compress_pdf.donut": "PDF sıkıştır", "preset.searchable_pdf": "Aranabilir PDF", "preset.long_image": "Uzun görsel", "preset.pdf_slides": "PPTX (slaytlar)", "preset.extract": "Arşivi aç", "preset.zip_all": "Tek ZIP", + "preset.zip_all.donut": "Tek ZIP", "preset.extract_subtitles": "Altyazıları çıkar", "preset.caption_video": "Altyazı", "preset.caption_video.donut": "Altyazı", @@ -513,12 +518,15 @@ "preset.trace_line_art": "Çizgileri SVG'ye dönüştür", "preset.trace_line_art.donut": "Çizgileri SVG'ye dönüştür", "preset.extract_frames_png": "Kareleri çıkar (PNG)", + "preset.extract_frames_png.donut": "Kareleri çıkar (PNG)", "preset.extract_frames_jpg": "Kareleri çıkar (JPG)", "preset.extract_motion": "Live Photo videosunu çıkar", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Görseli sıkıştır", + "preset.compress_image.donut": "Görseli sıkıştır", "preset.compress_video": "Videoyu sıkıştır", + "preset.compress_video.donut": "Videoyu sıkıştır", "preset.for_email": "E-posta için video (18 MB altı)", "preset.for_discord": "Discord için video (10 MB altı)", "preset.for_whatsapp": "WhatsApp için video (720p, 16 MB altı)", @@ -526,10 +534,14 @@ "preset.video_from_images": "Görsellerden video", "preset.extract_audio": "Sesi çıkar", "preset.merge_videos": "Videoları birleştir", + "preset.merge_videos.donut": "Videoları birleştir", "preset.compress_audio": "Sesi sıkıştır", "preset.merge_audio": "Sesleri birleştir", + "preset.merge_audio.donut": "Sesleri birleştir", "preset.merge_images": "Görselleri birleştir", + "preset.merge_images.donut": "Görselleri birleştir", "preset.remove_metadata": "Üst veriyi kaldır", + "preset.remove_metadata.donut": "Üst veriyi kaldır", "format.folder": "Klasör", "format.category.Image": "Görsel", "format.category.Pdf": "PDF", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 56b52a648..53420c683 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -349,6 +349,7 @@ "hide.state_done": "Xong", "preset.clip": "Cắt một đoạn", "preset.group_crop_video": "Cắt khung video", + "preset.group_crop_video.donut": "Cắt khung video", "preset.crop_video_auto": "Gỡ viền đen", "preset.crop_video_auto.short": "Tự động", "preset.crop_video_16_9": "Cắt khung 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "inch", "preset.grayscale": "Thang độ xám", "preset.remove_background": "Xóa nền", + "preset.remove_background.donut": "Xóa nền", "preset.trim_transparent": "Cắt bỏ viền trong suốt", "preset.group_rotate": "Xoay", "preset.rotate_left": "Xoay trái", @@ -454,6 +456,7 @@ "preset.flip_vertical": "Lật dọc", "preset.flip_vertical.short": "Dọc", "preset.merge_pdf": "Ghép PDF", + "preset.merge_pdf.donut": "Ghép PDF", "merge.order_title": "Chọn thứ tự ghép", "merge.order_hint": "Dùng các phím mũi tên để di chuyển giữa các thẻ. Để đổi thứ tự, hãy kéo một thẻ hoặc nhấn Alt + ↑ / Alt + ↓. Các tệp được ghép từ trên xuống dưới. Phím Delete bỏ tệp đang chọn.", "merge.sort_by": "Sắp xếp theo", @@ -510,11 +513,13 @@ "pdf_editor.saved": "Đã lưu {0}", "preset.split_pdf": "Tách PDF", "preset.compress_pdf": "Nén PDF", + "preset.compress_pdf.donut": "Nén PDF", "preset.searchable_pdf": "PDF có thể tìm kiếm", "preset.long_image": "Ảnh dài", "preset.pdf_slides": "PPTX (trang chiếu)", "preset.extract": "Giải nén", "preset.zip_all": "Một tệp ZIP", + "preset.zip_all.donut": "Một tệp ZIP", "preset.extract_subtitles": "Trích xuất phụ đề", "preset.caption_video": "Tạo phụ đề", "preset.caption_video.donut": "Phụ đề", @@ -523,12 +528,15 @@ "preset.trace_line_art": "Nét vẽ sang SVG", "preset.trace_line_art.donut": "Nét vẽ", "preset.extract_frames_png": "Trích xuất khung hình (PNG)", + "preset.extract_frames_png.donut": "Trích xuất khung hình (PNG)", "preset.extract_frames_jpg": "Trích xuất khung hình (JPG)", "preset.extract_motion": "Trích xuất video Live Photo", "preset.live_photo_iphone": "Live Photo (iPhone)", "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Nén ảnh", + "preset.compress_image.donut": "Nén ảnh", "preset.compress_video": "Nén video", + "preset.compress_video.donut": "Nén video", "preset.for_email": "Video cho email (dưới 18 MB)", "preset.for_discord": "Video cho Discord (dưới 10 MB)", "preset.for_whatsapp": "Video cho WhatsApp (720p, dưới 16 MB)", @@ -536,10 +544,14 @@ "preset.video_from_images": "Video từ ảnh", "preset.extract_audio": "Trích xuất âm thanh", "preset.merge_videos": "Ghép video", + "preset.merge_videos.donut": "Ghép video", "preset.compress_audio": "Nén âm thanh", "preset.merge_audio": "Ghép âm thanh", + "preset.merge_audio.donut": "Ghép âm thanh", "preset.merge_images": "Ghép ảnh", + "preset.merge_images.donut": "Ghép ảnh", "preset.remove_metadata": "Xóa siêu dữ liệu", + "preset.remove_metadata.donut": "Xóa siêu dữ liệu", "format.folder": "Thư mục", "format.category.Image": "Ảnh", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index a38a86981..634bc1ebb 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -349,6 +349,7 @@ "hide.state_done": "完成", "preset.clip": "剪切片段", "preset.group_crop_video": "裁剪视频画面", + "preset.group_crop_video.donut": "裁剪视频画面", "preset.crop_video_auto": "去除黑边", "preset.crop_video_auto.short": "自动", "preset.crop_video_16_9": "裁剪为 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "英寸", "preset.grayscale": "灰度", "preset.remove_background": "去除背景", + "preset.remove_background.donut": "去除背景", "preset.trim_transparent": "裁掉透明边框", "preset.group_rotate": "旋转", "preset.rotate_left": "向左旋转", @@ -454,6 +456,7 @@ "preset.flip_vertical": "垂直翻转", "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "合并 PDF", + "preset.merge_pdf.donut": "合并 PDF", "merge.order_title": "选择合并顺序", "merge.order_hint": "使用方向键在卡片之间移动。要更改顺序,请拖动卡片或按 Alt + ↑ / Alt + ↓。文件从上到下合并。按 Delete 移除所选文件。", "merge.sort_by": "排序方式", @@ -510,11 +513,13 @@ "pdf_editor.saved": "已保存:{0}", "preset.split_pdf": "拆分 PDF", "preset.compress_pdf": "压缩 PDF", + "preset.compress_pdf.donut": "压缩 PDF", "preset.searchable_pdf": "可搜索的 PDF", "preset.long_image": "长图", "preset.pdf_slides": "PPTX(幻灯片)", "preset.extract": "解压", "preset.zip_all": "打包成一个 ZIP", + "preset.zip_all.donut": "打包成一个 ZIP", "preset.extract_subtitles": "提取字幕", "preset.caption_video": "字幕", "preset.caption_video.donut": "字幕", @@ -523,12 +528,15 @@ "preset.trace_line_art": "黑白描摹", "preset.trace_line_art.donut": "黑白描摹", "preset.extract_frames_png": "提取帧 (PNG)", + "preset.extract_frames_png.donut": "提取帧 (PNG)", "preset.extract_frames_jpg": "提取帧 (JPG)", "preset.extract_motion": "提取实况/动态照片视频", "preset.live_photo_iphone": "实况照片(iPhone)", "preset.motion_photo_android": "动态照片(安卓)", "preset.compress_image": "压缩图片", + "preset.compress_image.donut": "压缩图片", "preset.compress_video": "压缩视频", + "preset.compress_video.donut": "压缩视频", "preset.for_email": "用于邮件的视频(18 MB 以下)", "preset.for_discord": "用于 Discord 的视频(10 MB 以下)", "preset.for_whatsapp": "用于 WhatsApp 的视频(720p,16 MB 以下)", @@ -536,10 +544,14 @@ "preset.video_from_images": "图片生成视频", "preset.extract_audio": "提取音频", "preset.merge_videos": "合并视频", + "preset.merge_videos.donut": "合并视频", "preset.compress_audio": "压缩音频", "preset.merge_audio": "合并音频", + "preset.merge_audio.donut": "合并音频", "preset.merge_images": "合并图片", + "preset.merge_images.donut": "合并图片", "preset.remove_metadata": "移除元数据", + "preset.remove_metadata.donut": "移除元数据", "format.folder": "文件夹", "format.category.Image": "图片", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index ce2dc5826..e19675aca 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -349,6 +349,7 @@ "hide.state_done": "完成", "preset.clip": "剪輯片段", "preset.group_crop_video": "裁剪影片畫面", + "preset.group_crop_video.donut": "裁剪影片畫面", "preset.crop_video_auto": "移除黑邊", "preset.crop_video_auto.short": "自動", "preset.crop_video_16_9": "裁剪為 16:9", @@ -437,6 +438,7 @@ "image_edit.unit.Inches": "英吋", "preset.grayscale": "灰階", "preset.remove_background": "去除背景", + "preset.remove_background.donut": "去除背景", "preset.trim_transparent": "裁掉透明邊框", "preset.group_rotate": "旋轉", "preset.rotate_left": "向左旋轉", @@ -454,6 +456,7 @@ "preset.flip_vertical": "垂直翻轉", "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "合併 PDF", + "preset.merge_pdf.donut": "合併 PDF", "merge.order_title": "選擇合併順序", "merge.order_hint": "使用方向鍵在卡片之間移動。要變更順序,請拖曳卡片或按 Alt + ↑ / Alt + ↓。檔案由上到下合併。按 Delete 移除所選檔案。", "merge.sort_by": "排序方式", @@ -510,11 +513,13 @@ "pdf_editor.saved": "已儲存:{0}", "preset.split_pdf": "拆分 PDF", "preset.compress_pdf": "壓縮 PDF", + "preset.compress_pdf.donut": "壓縮 PDF", "preset.searchable_pdf": "可搜尋的 PDF", "preset.long_image": "長圖", "preset.pdf_slides": "PPTX(投影片)", "preset.extract": "解壓縮", "preset.zip_all": "打包成一個 ZIP", + "preset.zip_all.donut": "打包成一個 ZIP", "preset.extract_subtitles": "擷取字幕", "preset.caption_video": "字幕", "preset.caption_video.donut": "字幕", @@ -523,12 +528,15 @@ "preset.trace_line_art": "黑白描摹", "preset.trace_line_art.donut": "黑白描摹", "preset.extract_frames_png": "擷取影格 (PNG)", + "preset.extract_frames_png.donut": "擷取影格 (PNG)", "preset.extract_frames_jpg": "擷取影格 (JPG)", "preset.extract_motion": "擷取原況/動態相片影片", "preset.live_photo_iphone": "原況照片(iPhone)", "preset.motion_photo_android": "動態相片(Android)", "preset.compress_image": "壓縮圖片", + "preset.compress_image.donut": "壓縮圖片", "preset.compress_video": "壓縮影片", + "preset.compress_video.donut": "壓縮影片", "preset.for_email": "用於郵件的影片(18 MB 以下)", "preset.for_discord": "用於 Discord 的影片(10 MB 以下)", "preset.for_whatsapp": "用於 WhatsApp 的影片(720p,16 MB 以下)", @@ -536,10 +544,14 @@ "preset.video_from_images": "圖片製成影片", "preset.extract_audio": "擷取音訊", "preset.merge_videos": "合併影片", + "preset.merge_videos.donut": "合併影片", "preset.compress_audio": "壓縮音訊", "preset.merge_audio": "合併音訊", + "preset.merge_audio.donut": "合併音訊", "preset.merge_images": "合併圖片", + "preset.merge_images.donut": "合併圖片", "preset.remove_metadata": "移除中繼資料", + "preset.remove_metadata.donut": "移除中繼資料", "format.folder": "資料夾", "format.category.Image": "圖片", From 286f83cdb6397ecc7972590e5a98407e196bf757 Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Tue, 10 Nov 2026 15:28:58 -0600 Subject: [PATCH 6/6] test(donut): one sweep case per language, under CI's five-minute hang limit SliceLabelFitTests and SliceLabelReadabilityTests each swept every language in one test, 11 and 6 minutes; CI's --blame-hang-timeout 5m aborted the run in them on every runner. Each language is now its own case (the slowest about a minute), with the same checks; the readability report is written per language. Co-Authored-By: Claude Opus 5.5 --- tests/Filee.App.Tests/SliceLabelFitTests.cs | 10 +++++++--- tests/Filee.App.Tests/SliceLabelReadabilityTests.cs | 13 ++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/Filee.App.Tests/SliceLabelFitTests.cs b/tests/Filee.App.Tests/SliceLabelFitTests.cs index 0bfb876dc..d410910f5 100644 --- a/tests/Filee.App.Tests/SliceLabelFitTests.cs +++ b/tests/Filee.App.Tests/SliceLabelFitTests.cs @@ -34,13 +34,17 @@ from show in Enum.GetValues() from ring in Rings select (radius, scale, show, ring)]; - [AvaloniaFact] - public void Every_label_of_every_built_in_donut_fits_its_slice_in_every_language() + // One case per language: each runs in well under CI's five-minute hang limit, and a failure names its language. + public static TheoryData AppLanguageCodes => [.. AppLanguages.All.Select(l => l.Code)]; + + [AvaloniaTheory] + [MemberData(nameof(AppLanguageCodes))] + public void Every_label_of_every_built_in_donut_fits_its_slice_in_every_language(string code) { var problems = new List(); try { - foreach (var language in AppLanguages.All.Select(l => l.Code)) + foreach (var language in new[] { code }) { TestServices.EnsureInitialized(language); var loc = AppHost.Get(); diff --git a/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs b/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs index a9ea03afd..7889f1532 100644 --- a/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs +++ b/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs @@ -40,20 +40,23 @@ private static readonly (double Radius, double Hole, double Scale, LabelShow Sho from ring in new[] { (1.0, 1.0), (1.5, 1.9), (0.75, 3.0) } select (radius, hole, scale, show, ring.Item1, ring.Item2)]; - [AvaloniaFact] - public void Every_label_is_the_most_readable_that_fits_in_every_script() + // One case per script: each runs in well under CI's five-minute hang limit, and a failure names its language. + public static TheoryData ScriptLanguages => [.. Languages]; + + [AvaloniaTheory] + [MemberData(nameof(ScriptLanguages))] + public void Every_label_is_the_most_readable_that_fits_in_every_script(string language) { var problems = new List(); try { - foreach (var language in Languages) - Sweep(language, null, Settings, problems); + Sweep(language, null, Settings, problems); } finally { TestServices.EnsureInitialized("en"); } - Report(problems); + Report(problems, $"{nameof(Every_label_is_the_most_readable_that_fits_in_every_script)}-{language}"); } [AvaloniaFact]