diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0b7dbcde4..41f924dac 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -287,6 +287,82 @@ 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`
+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 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
+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..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",
@@ -725,6 +737,14 @@
"toolbar.hole": "الفتحة الوسطى",
"toolbar.opacity": "العتامة",
"toolbar.captions": "إظهار صيغة الناتج",
+ "toolbar.slice_labels": "تسميات القطاعات",
+ "toolbar.label_scale": "حجم التسميات",
+ "toolbar.ring_size": "حجم حلقة المجموعة",
+ "toolbar.ring_spread": "اتساع حلقة المجموعة",
+ "labels.auto": "تلقائي",
+ "labels.icon_text": "أيقونة ونص",
+ "labels.text_only": "نص فقط",
+ "labels.icon_only": "أيقونة فقط",
"toolbar.new_preset": "إعداد مسبق جديد",
"toolbar.new_group": "مجموعة جديدة",
"toolbar.face_front": "الأمام",
@@ -788,6 +808,27 @@
"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": "إعداد الشريط الدائري",
+ "presets.direction": "اتجاه النص",
+ "labels.dir_straight": "مستقيم",
+ "labels.dir_out": "من المركز إلى الخارج",
+ "labels.dir_ring": "على طول الحلقة",
+ "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..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": "পিডিএফ",
@@ -724,6 +736,14 @@
"toolbar.hole": "ছিদ্র",
"toolbar.opacity": "অস্বচ্ছতা",
"toolbar.captions": "আউটপুট ফরম্যাট দেখান",
+ "toolbar.slice_labels": "স্লাইসের লেবেল",
+ "toolbar.label_scale": "লেবেলের আকার",
+ "toolbar.ring_size": "গ্রুপ রিংয়ের আকার",
+ "toolbar.ring_spread": "গ্রুপ রিংয়ের বিস্তার",
+ "labels.auto": "স্বয়ংক্রিয়",
+ "labels.icon_text": "আইকন ও টেক্সট",
+ "labels.text_only": "কেবল টেক্সট",
+ "labels.icon_only": "কেবল আইকন",
"toolbar.new_preset": "নতুন প্রিসেট",
"toolbar.new_group": "নতুন গ্রুপ",
"toolbar.face_front": "সামনে",
@@ -787,6 +807,27 @@
"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": "ডোনাটের সেটিং ব্যবহার করুন",
+ "presets.direction": "টেক্সটের দিক",
+ "labels.dir_straight": "সোজা",
+ "labels.dir_out": "কেন্দ্র থেকে বাইরে",
+ "labels.dir_ring": "রিং বরাবর",
+ "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..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",
@@ -725,6 +737,14 @@
"toolbar.hole": "Innenöffnung",
"toolbar.opacity": "Deckkraft",
"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",
+ "labels.icon_only": "Nur Symbol",
"toolbar.new_preset": "Neue Vorlage",
"toolbar.new_group": "Neue Gruppe",
"toolbar.face_front": "Vorderseite",
@@ -788,6 +808,27 @@
"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",
+ "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",
+ "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..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",
@@ -739,6 +751,14 @@
"toolbar.hole": "Hole",
"toolbar.opacity": "Opacity",
"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",
+ "labels.icon_only": "Icon only",
"toolbar.new_preset": "New preset",
"toolbar.new_group": "New group",
"toolbar.face_front": "Front",
@@ -803,6 +823,27 @@
"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",
+ "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",
+ "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..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",
@@ -725,6 +737,14 @@
"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",
+ "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",
+ "labels.icon_only": "Solo icono",
"toolbar.new_preset": "Nuevo ajuste",
"toolbar.new_group": "Nuevo grupo",
"toolbar.face_front": "Anverso",
@@ -788,6 +808,27 @@
"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",
+ "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",
+ "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..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",
@@ -724,6 +736,14 @@
"toolbar.hole": "حفره میانی",
"toolbar.opacity": "شفافیت",
"toolbar.captions": "نمایش فرمت خروجی",
+ "toolbar.slice_labels": "برچسب بخشها",
+ "toolbar.label_scale": "اندازه برچسبها",
+ "toolbar.ring_size": "اندازه حلقه گروه",
+ "toolbar.ring_spread": "گستردگی حلقه گروه",
+ "labels.auto": "خودکار",
+ "labels.icon_text": "آیکون و متن",
+ "labels.text_only": "فقط متن",
+ "labels.icon_only": "فقط آیکون",
"toolbar.new_preset": "پیشتنظیم جدید",
"toolbar.new_group": "گروه جدید",
"toolbar.face_front": "رو",
@@ -787,6 +807,27 @@
"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": "استفاده از تنظیم دونات",
+ "presets.direction": "جهت متن",
+ "labels.dir_straight": "صاف",
+ "labels.dir_out": "از مرکز به بیرون",
+ "labels.dir_ring": "در امتداد حلقه",
+ "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..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",
@@ -739,6 +751,14 @@
"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",
+ "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",
+ "labels.icon_only": "Icône seule",
"toolbar.new_preset": "Préréglage",
"toolbar.new_group": "Groupe",
"toolbar.face_front": "Recto",
@@ -803,6 +823,27 @@
"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",
+ "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",
+ "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..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",
@@ -725,6 +737,14 @@
"toolbar.hole": "छेद",
"toolbar.opacity": "अपारदर्शिता",
"toolbar.captions": "आउटपुट फ़ॉर्मैट दिखाएँ",
+ "toolbar.slice_labels": "स्लाइस लेबल",
+ "toolbar.label_scale": "लेबल का आकार",
+ "toolbar.ring_size": "समूह रिंग का आकार",
+ "toolbar.ring_spread": "समूह रिंग का फैलाव",
+ "labels.auto": "अपने आप",
+ "labels.icon_text": "आइकन और टेक्स्ट",
+ "labels.text_only": "सिर्फ़ टेक्स्ट",
+ "labels.icon_only": "सिर्फ़ आइकन",
"toolbar.new_preset": "नया प्रीसेट",
"toolbar.new_group": "नया समूह",
"toolbar.face_front": "आगे",
@@ -788,6 +808,27 @@
"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": "डोनट की सेटिंग",
+ "presets.direction": "टेक्स्ट की दिशा",
+ "labels.dir_straight": "सीधा",
+ "labels.dir_out": "बीच से बाहर की ओर",
+ "labels.dir_ring": "रिंग के साथ-साथ",
+ "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..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",
@@ -725,6 +737,14 @@
"toolbar.hole": "Lubang tengah",
"toolbar.opacity": "Transparansi",
"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",
+ "labels.icon_only": "Hanya ikon",
"toolbar.new_preset": "Preset baru",
"toolbar.new_group": "Grup baru",
"toolbar.face_front": "Depan",
@@ -788,6 +808,27 @@
"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",
+ "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",
+ "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..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",
@@ -724,6 +736,14 @@
"toolbar.hole": "Foro",
"toolbar.opacity": "Opacità",
"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",
+"labels.icon_only": "Solo icona",
"toolbar.new_preset": "Nuovo preset",
"toolbar.new_group": "Nuovo gruppo",
"toolbar.face_front": "Fronte",
@@ -787,6 +807,27 @@
"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",
+"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",
+"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..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",
@@ -725,6 +737,14 @@
"toolbar.hole": "中央の穴",
"toolbar.opacity": "不透明度",
"toolbar.captions": "出力形式を表示",
+ "toolbar.slice_labels": "項目のラベル",
+ "toolbar.label_scale": "ラベルの大きさ",
+ "toolbar.ring_size": "グループリングの大きさ",
+ "toolbar.ring_spread": "グループリングの広がり",
+ "labels.auto": "自動",
+ "labels.icon_text": "アイコンとテキスト",
+ "labels.text_only": "テキストのみ",
+ "labels.icon_only": "アイコンのみ",
"toolbar.new_preset": "新規作成",
"toolbar.new_group": "グループ作成",
"toolbar.face_front": "表面",
@@ -788,6 +808,27 @@
"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": "ドーナツメニューの設定に従う",
+ "presets.direction": "文字の向き",
+ "labels.dir_straight": "水平",
+ "labels.dir_out": "中心から外向き",
+ "labels.dir_ring": "リングに沿って",
+ "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..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": "이미지",
@@ -739,6 +751,14 @@
"toolbar.hole": "구멍",
"toolbar.opacity": "불투명도",
"toolbar.captions": "출력 형식 표시",
+ "toolbar.slice_labels": "세그먼트 레이블",
+ "toolbar.label_scale": "레이블 크기",
+ "toolbar.ring_size": "그룹 링 크기",
+ "toolbar.ring_spread": "그룹 링 펼침 폭",
+ "labels.auto": "자동",
+ "labels.icon_text": "아이콘과 텍스트",
+ "labels.text_only": "텍스트만",
+ "labels.icon_only": "아이콘만",
"toolbar.new_preset": "새 프리셋",
"toolbar.new_group": "새 묶음",
"toolbar.face_front": "앞면",
@@ -803,6 +823,27 @@
"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": "도넛 설정 따르기",
+ "presets.direction": "텍스트 방향",
+ "labels.dir_straight": "가로",
+ "labels.dir_out": "가운데에서 바깥쪽",
+ "labels.dir_ring": "링을 따라",
+ "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..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",
@@ -725,6 +737,14 @@
"toolbar.hole": "प्वाल (केन्द्र)",
"toolbar.opacity": "अपारदर्शिता",
"toolbar.captions": "आउटपुट ढाँचा देखाउनुहोस्",
+ "toolbar.slice_labels": "स्लाइस लेबल",
+ "toolbar.label_scale": "लेबलको आकार",
+ "toolbar.ring_size": "समूह रिङको आकार",
+ "toolbar.ring_spread": "समूह रिङको फैलावट",
+ "labels.auto": "स्वचालित",
+ "labels.icon_text": "आइकन र पाठ",
+ "labels.text_only": "पाठ मात्र",
+ "labels.icon_only": "आइकन मात्र",
"toolbar.new_preset": "नयाँ प्रिसेट",
"toolbar.new_group": "नयाँ समूह",
"toolbar.face_front": "अगाडि",
@@ -788,6 +808,27 @@
"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": "डोनटको सेटिङ",
+ "presets.direction": "पाठको दिशा",
+ "labels.dir_straight": "सीधा",
+ "labels.dir_out": "बीचबाट बाहिरतिर",
+ "labels.dir_ring": "रिङसँगै",
+ "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..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",
@@ -739,6 +751,14 @@
"toolbar.hole": "Otwór",
"toolbar.opacity": "Krycie",
"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",
+ "labels.icon_only": "Tylko ikona",
"toolbar.new_preset": "Nowe ustawienie",
"toolbar.new_group": "Nowa grupa",
"toolbar.face_front": "Przód",
@@ -803,6 +823,27 @@
"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",
+ "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",
+ "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..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",
@@ -739,6 +751,14 @@
"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",
+ "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",
+ "labels.icon_only": "Só ícone",
"toolbar.new_preset": "Nova predefinição",
"toolbar.new_group": "Novo grupo",
"toolbar.face_front": "Frente",
@@ -803,6 +823,27 @@
"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",
+ "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",
+ "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..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",
@@ -739,6 +751,14 @@
"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",
+ "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",
+ "labels.icon_only": "Só ícone",
"toolbar.new_preset": "Nova predefinição",
"toolbar.new_group": "Novo grupo",
"toolbar.face_front": "Frente",
@@ -803,6 +823,27 @@
"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",
+ "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",
+ "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..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": "Изображение",
@@ -739,6 +751,14 @@
"toolbar.hole": "Отверстие",
"toolbar.opacity": "Непрозрачность",
"toolbar.captions": "Показывать формат результата",
+ "toolbar.slice_labels": "Подписи секторов",
+ "toolbar.label_scale": "Размер подписей",
+ "toolbar.ring_size": "Размер кольца группы",
+ "toolbar.ring_spread": "Ширина кольца группы",
+ "labels.auto": "Авто",
+ "labels.icon_text": "Значок и текст",
+ "labels.text_only": "Только текст",
+ "labels.icon_only": "Только значок",
"toolbar.new_preset": "Новый пресет",
"toolbar.new_group": "Новая группа",
"toolbar.face_front": "Лицевая",
@@ -803,6 +823,27 @@
"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": "Как в настройках меню",
+ "presets.direction": "Направление текста",
+ "labels.dir_straight": "Прямо",
+ "labels.dir_out": "От центра наружу",
+ "labels.dir_ring": "Вдоль кольца",
+ "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..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",
@@ -725,6 +737,14 @@
"toolbar.hole": "ขนาดรูกลาง",
"toolbar.opacity": "ความทึบแสง",
"toolbar.captions": "แสดงฟอร์แมตผลลัพธ์",
+ "toolbar.slice_labels": "ป้ายชิ้นส่วน",
+ "toolbar.label_scale": "ขนาดป้าย",
+ "toolbar.ring_size": "ขนาดวงแหวนกลุ่ม",
+ "toolbar.ring_spread": "ความกว้างวงแหวนกลุ่ม",
+ "labels.auto": "อัตโนมัติ",
+ "labels.icon_text": "ไอคอนและข้อความ",
+ "labels.text_only": "ข้อความเท่านั้น",
+ "labels.icon_only": "ไอคอนเท่านั้น",
"toolbar.new_preset": "พรีเซ็ตใหม่",
"toolbar.new_group": "กลุ่มใหม่",
"toolbar.face_front": "ด้านหน้า",
@@ -788,6 +808,27 @@
"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": "ใช้การตั้งค่าของโดนัท",
+ "presets.direction": "ทิศทางข้อความ",
+ "labels.dir_straight": "แนวนอน",
+ "labels.dir_out": "จากกลางออกไป",
+ "labels.dir_ring": "ตามวงแหวน",
+ "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..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",
@@ -725,6 +737,14 @@
"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",
+ "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",
+ "labels.icon_only": "Yalnızca simge",
"toolbar.new_preset": "Yeni ön ayar",
"toolbar.new_group": "Yeni grup",
"toolbar.face_front": "Ön",
@@ -788,6 +808,27 @@
"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",
+ "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",
+ "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..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",
@@ -739,6 +751,14 @@
"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",
+ "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ữ",
+ "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 +823,27 @@
"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",
+ "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",
+ "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..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": "图片",
@@ -739,6 +751,14 @@
"toolbar.hole": "内圈",
"toolbar.opacity": "不透明度",
"toolbar.captions": "显示输出格式",
+ "toolbar.slice_labels": "扇区标签",
+ "toolbar.label_scale": "标签大小",
+ "toolbar.ring_size": "分组圆环大小",
+ "toolbar.ring_spread": "分组圆环展开宽度",
+ "labels.auto": "自动",
+ "labels.icon_text": "图标和文字",
+ "labels.text_only": "仅文字",
+ "labels.icon_only": "仅图标",
"toolbar.new_preset": "新预设",
"toolbar.new_group": "新建组",
"toolbar.face_front": "正面",
@@ -803,6 +823,27 @@
"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": "使用圆环的设置",
+ "presets.direction": "文字方向",
+ "labels.dir_straight": "水平",
+ "labels.dir_out": "从中心向外",
+ "labels.dir_ring": "沿着圆环",
+ "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..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": "圖片",
@@ -739,6 +751,14 @@
"toolbar.hole": "內圈",
"toolbar.opacity": "不透明度",
"toolbar.captions": "顯示輸出格式",
+ "toolbar.slice_labels": "扇形標籤",
+ "toolbar.label_scale": "標籤大小",
+ "toolbar.ring_size": "群組圓環大小",
+ "toolbar.ring_spread": "群組圓環展開寬度",
+ "labels.auto": "自動",
+ "labels.icon_text": "圖示和文字",
+ "labels.text_only": "僅文字",
+ "labels.icon_only": "僅圖示",
"toolbar.new_preset": "新預設",
"toolbar.new_group": "新增群組",
"toolbar.face_front": "正面",
@@ -803,6 +823,27 @@
"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": "使用圓環的設定",
+ "presets.direction": "文字方向",
+ "labels.dir_straight": "水平",
+ "labels.dir_out": "從中心向外",
+ "labels.dir_ring": "沿著圓環",
+ "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/DonutGeometry.cs b/src/Filee.App/Controls/DonutGeometry.cs
index d1317a4db..454dffb29 100644
--- a/src/Filee.App/Controls/DonutGeometry.cs
+++ b/src/Filee.App/Controls/DonutGeometry.cs
@@ -73,23 +73,35 @@ 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.
+ /// 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)
+ 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, 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.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..c0ec793cd 100644
--- a/src/Filee.App/Controls/DonutMenu.Groups.cs
+++ b/src/Filee.App/Controls/DonutMenu.Groups.cs
@@ -19,17 +19,36 @@ 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;
- /// 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));
+ ///
+ /// 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, 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.
@@ -63,7 +82,14 @@ public sealed partial class DonutMenu
private IReadOnlyList? ExpandedChildren =>
!IsEditMode && _expanded >= 0 && _expanded < Count ? Items![_expanded].Children : null;
- private (double Inner, double Outer) SubRadii => (OuterRadius + PopOut + SubGap, OuterRadius + PopOut + SubGap + SubBand);
+ ///
+ /// This donut's ring scale (). Its labels follow it, and the Label scale as far as the
+ /// ring's slices have room ().
+ ///
+ private double RingScale => RingScaleFor(OuterRadius, GroupRingSize);
+
+ 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;
@@ -85,7 +111,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)
@@ -419,6 +446,9 @@ 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'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))
using (context.PushOpacity(Math.Clamp(t, 0, 1) * opening))
{
@@ -431,8 +461,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..3a95641d2 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,23 @@ 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; }
+
+ ///
+ /// 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;
@@ -76,8 +95,16 @@ 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);
+ 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 =
@@ -142,11 +169,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);
+ CenterHintProperty, CenterHintWheelProperty, ShowCaptionsProperty, LabelScaleProperty, DefaultShowProperty,
+ GroupRingSizeProperty, GroupRingSpreadProperty);
ItemsProperty.Changed.AddClassHandler((d, _) => d.OnItemsChanged());
}
@@ -191,6 +219,23 @@ 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); }
+
+ ///
+ /// 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); }
+
public CornerRadius CornerRadius { get => GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); }
public IBrush? SliceBrush { get => GetValue(SliceBrushProperty); set => SetValue(SliceBrushProperty, value); }
@@ -421,7 +466,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);
}
@@ -798,7 +843,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;
@@ -899,6 +944,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 +988,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 +1015,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 +1053,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 +1151,148 @@ 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);
+ // 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 ? ChevronIn : 0, item.IsGroup ? ChevronReach : 0),
+ item.Direction ?? LabelDirection.Auto,
+ NeedsCaption: item.KeepsCaption || SharesItsName(item, items));
+ }).ToList();
+ PlansMade++;
+ return _labelPlans[key] = fitScale
+ ? layout.PlanRingToFit(inputs, LabelScale, scale, ShowCaptions)
+ : layout.PlanRing(inputs, LabelScale, scale, ShowCaptions);
+ }
- /// 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)
+ /// 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 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 items = Items ?? [];
+ var n = items.Count;
+ 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;
+ 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);
}
+ ///
+ /// 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;
+ 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;
+ 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)))
+ {
+ 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 +1304,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 +1339,24 @@ 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…".
+ /// 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.
///
- 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();
+ private double ChevronIn => (GroupMarkInset + GroupMarkDepth / 2) * DonutScale;
- ///
- /// 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;
- }
+ ///
+ 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.
- 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 +1449,17 @@ 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 ? ChevronIn : 0, _previewGroup ? ChevronReach : 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..b6ef67250
--- /dev/null
+++ b/src/Filee.App/Controls/SliceIcons.Data.cs
@@ -0,0 +1,206 @@
+// 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",
+ ["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
new file mode 100644
index 000000000..c435b43a0
--- /dev/null
+++ b/src/Filee.App/Controls/SliceIcons.cs
@@ -0,0 +1,102 @@
+// 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 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.
+ ///
+ 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..fc644fcb3
--- /dev/null
+++ b/src/Filee.App/Controls/SliceLabelLayout.cs
@@ -0,0 +1,1309 @@
+// 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'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…").
+// 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
+/// ). 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 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)
+{
+ /// 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,
+ Point KeepOut = default, double KeepOutRadius = 0)
+{
+ ///
+ /// 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));
+ // 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;
+
+ /// 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);
+
+ ///
+ /// 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)
+ 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;
+ 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)
+ {
+ 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, 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
+ /// (), each as wide as says. Its middle is
+ /// ; isn't used.
+ ///
+ internal bool Curved => CurvedWidths is not null;
+
+ /// Its outline, for straight text: its lines' boxes, room above and below the letters included.
+ internal LabelBox Box => new(Anchor, Width, Height, Rotation);
+
+ ///
+ /// 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 }
+
+/// 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);
+
+ ///
+ /// 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;
+
+ ///
+ /// 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, 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, 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.
+ ///
+ 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 = 10, 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;
+
+ // 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 Size, double InkTop, double InkBottom, double InkWidth)> _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, ring: true);
+ 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 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 ring = false)
+ {
+ var baseSize = NameSizeFor(labelScale, 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();
+ 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];
+ // 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) => !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;
+
+ // 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);
+ // 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 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 = 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;
+ return new SliceLabel(SliceLabelKind.Text, block, null, default, 0, zones[i]);
+ }
+ 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 (Whole(i, size, way, mustKeepNeeded: false) is not { } alone)
+ return false;
+ return (alone.Text!.Caption is not null && both.Text!.Caption is null) || (OnMiddle(alone) && !OnMiddle(both));
+ }
+
+ // 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)
+ {
+ 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;
+ }
+
+ // 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)
+ {
+ 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)
+ {
+ 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));
+ }
+ }
+
+ 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)
+ {
+ // 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();
+ }
+ 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)
+ {
+ // 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]));
+ }
+
+ 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 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 (alone.Count > 0)
+ {
+ 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();
+ 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 ring's), and the scale of its icon.
+ return result.Select((r, i) =>
+ {
+ 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 ).
+ /// 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, 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 middle in new[] { true, false })
+ foreach (var withCaption in caption is null ? new[] { false } : new[] { true, false })
+ {
+ 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 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)
+ {
+ 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--)
+ {
+ 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 (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, IconPlacement placement = IconPlacement.Either, bool captionNeeded = false)
+ {
+ 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));
+ // 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--)
+ {
+ 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
+ // 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 && ((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 && 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 middle in new[] { true, false })
+ foreach (var beside in new[] { false, true })
+ {
+ 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 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 inkTop, double inkBottom, IReadOnlyList rows, bool middleOnly)
+ {
+ var textHeight = heights.Sum() + captionHeight;
+ 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 icon = new Point(at.X, at.Y - height / 2 + iconSize / 2);
+ 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;
+ }
+
+ ///
+ /// 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,
+ 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 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 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), 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)
+ {
+ 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.
+
+ ///
+ /// 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;
+ }
+
+ ///
+ /// 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.OuterOnMiddle + 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, bool middleOnly = false)
+ {
+ 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;
+ }
+ // 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)
+ {
+ 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;
+ }
+
+ 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) => 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();
+ // 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)>();
+ foreach (var word in name.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (!word.Any(DonutMenu.IsHanOrKana))
+ {
+ units.Add((word, units.Count > 0));
+ continue;
+ }
+ // 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))
+ {
+ 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.
+ ///
+ 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/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/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..877a30db2
--- /dev/null
+++ b/src/Filee.App/ViewModels/IconPickerViewModel.cs
@@ -0,0 +1,115 @@
+// 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..04e178fc4 100644
--- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs
@@ -125,6 +125,10 @@ 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;
SelectedProfile = Profiles.FirstOrDefault();
@@ -301,6 +305,48 @@ 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 => Percent(LabelScale);
+
+ ///
+ /// 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 +487,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;
}
@@ -456,6 +506,43 @@ 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];
+
+ /// 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()
{
@@ -465,6 +552,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();
}
@@ -723,7 +812,18 @@ 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,
+ Direction = p.Direction,
+ })
.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..93da48e00 100644
--- a/src/Filee.App/ViewModels/RadialViewModel.cs
+++ b/src/Filee.App/ViewModels/RadialViewModel.cs
@@ -28,6 +28,22 @@ 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;
+
+ ///
+ /// 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 +191,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 +302,10 @@ 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,
+ Direction = p.Direction,
};
}
@@ -296,13 +318,17 @@ 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,
+ Direction = p.Direction,
};
}).ToList();
return new Face(presets, subPresets, items);
@@ -317,17 +343,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).
@@ -340,6 +369,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/ViewModels/SliceLabelFields.cs b/src/Filee.App/ViewModels/SliceLabelFields.cs
new file mode 100644
index 000000000..e287a25ef
--- /dev/null
+++ b/src/Filee.App/ViewModels/SliceLabelFields.cs
@@ -0,0 +1,101 @@
+// 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.
+
+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];
+ 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.
+ [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);
+
+ /// 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)
+ {
+ _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;
+ preset.Direction = Direction.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
@@ -337,6 +339,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -450,6 +467,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -65,6 +66,9 @@
+
+
+
diff --git a/src/Filee.App/Views/RadialWindow.axaml b/src/Filee.App/Views/RadialWindow.axaml
index 198012ed6..022c558bf 100644
--- a/src/Filee.App/Views/RadialWindow.axaml
+++ b/src/Filee.App/Views/RadialWindow.axaml
@@ -48,6 +48,10 @@
HoleRatio="{Binding HoleRatio}"
SliceOpacity="{Binding SliceOpacity}"
ShowCaptions="{Binding ShowCaptions}"
+ LabelScale="{Binding LabelScale}"
+ DefaultShow="{Binding DefaultShow}"
+ GroupRingSize="{Binding GroupRingSize}"
+ GroupRingSpread="{Binding GroupRingSpread}"
ReserveSubRing="{Binding ReserveSubRing}"
CanFlip="{Binding CanFlip}"
CenterHint="{Binding FlipHint}"
diff --git a/src/Filee.App/Views/SliceLabelFieldsView.axaml b/src/Filee.App/Views/SliceLabelFieldsView.axaml
new file mode 100644
index 000000000..cba9c0302
--- /dev/null
+++ b/src/Filee.App/Views/SliceLabelFieldsView.axaml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs b/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs
new file mode 100644
index 000000000..02f295fdb
--- /dev/null
+++ b/src/Filee.App/Views/SliceLabelFieldsView.axaml.cs
@@ -0,0 +1,11 @@
+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..bd07da86c 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,13 @@ 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/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
new file mode 100644
index 000000000..b72e7e2a0
--- /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 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,
+
+ /// 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..550143965 100644
--- a/src/Filee.Core/Presets/Preset.cs
+++ b/src/Filee.Core/Presets/Preset.cs
@@ -146,6 +146,32 @@ 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; }
+
+ ///
+ /// 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;
@@ -404,6 +430,10 @@ public Preset ForSharing()
Upscale = Upscale.Clone(),
Metadata = Metadata?.Clone(),
Output = Output.Clone(),
+ Icon = Icon,
+ ShortName = ShortName,
+ Show = Show,
+ Direction = Direction,
Kind = Kind,
Tool = Tool,
SubPresetIds = [.. SubPresetIds],
diff --git a/src/Filee.Core/Profiles/ToolbarProfile.cs b/src/Filee.Core/Profiles/ToolbarProfile.cs
index f11f9e0a9..49401a18e 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,27 @@ 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 +80,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/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index a7de18ee7..3037c9146 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -81,6 +81,37 @@ 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.
+ /// 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 d8d7825a8..361556f9a 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -292,12 +292,16 @@ 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)
{
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/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/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/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/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/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/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..0628d96b4
--- /dev/null
+++ b/tests/Filee.App.Tests/RadialLabelTests.cs
@@ -0,0 +1,113 @@
+// 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
+ 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);
+ 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..f7e9feaae
--- /dev/null
+++ b/tests/Filee.App.Tests/SliceIconsTests.cs
@@ -0,0 +1,102 @@
+// 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
+ }
+ // 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_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()
+ {
+ 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..6d3d02313
--- /dev/null
+++ b/tests/Filee.App.Tests/SliceLabelEditorTests.cs
@@ -0,0 +1,132 @@
+// 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, 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";
+ 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);
+ Assert.Equal(LabelDirection.AlongRing, rotate.Direction);
+ Assert.Equal(LabelDirection.AlongRing, item.Direction);
+ }
+ finally
+ {
+ (rotate.Icon, rotate.ShortName, rotate.Show, rotate.Direction) = 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..d410910f5
--- /dev/null
+++ b/tests/Filee.App.Tests/SliceLabelFitTests.cs
@@ -0,0 +1,268 @@
+// 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, 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;
+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];
+
+ // 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)];
+
+ // 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 new[] { 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, scale, show, ring) in Settings)
+ 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);
+ }
+ }
+ }
+ }
+ finally
+ {
+ TestServices.EnsureInitialized("en");
+ }
+ 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)
+ {
+ 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),
+ Direction = direction,
+ KeepsCaption = !ring && p.IsCopyToFolder,
+ };
+ 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,
+ Direction = direction,
+ };
+ }).ToList();
+ }
+
+ private static void Check(IReadOnlyList ring, IReadOnlyList items, string where, List problems,
+ LabelShow show)
+ {
+ // 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" };
+ for (var i = 0; i < ring.Count; i++)
+ {
+ 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 (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)
+ problems.Add($"{where}: names run in different directions");
+ 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");
+ 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 })
+ {
+ 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)
+ {
+ if (!text.Inside(label.Zone))
+ 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..929fd8e1c
--- /dev/null
+++ b/tests/Filee.App.Tests/SliceLabelLayoutTests.cs
@@ -0,0 +1,862 @@
+// 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, one
+// size per ring, and in Auto one style per ring (Show).
+
+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;
+
+ // 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;
+
+ 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");
+ 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 = 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 floor, scaled with the donut.
+ var thin = SafeZone.Of(new SliceShape(Center, 100, 140, 0, 0.5, Gap), scale: 1.6);
+ Assert.Equal(100 + SafeZone.MinMargin * 1.6, thin.Inner, 6);
+ }
+
+ [Fact]
+ public void A_group_slice_keeps_clear_of_its_chevron_only()
+ {
+ // 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]
+ 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]
+ // 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("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();
+ 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);
+ }
+
+ [Fact]
+ public void Text_level_with_the_ring_is_never_upside_down_and_crosses_the_slice()
+ {
+ for (var mid = 0.0; mid < 2 * Math.PI; mid += 0.05)
+ {
+ var turn = SliceLabelLayout.Across(mid);
+ Assert.True(Math.Cos(turn) >= -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)
+ {
+ // 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}");
+ Assert.True(block!.Inside(zone), $"{name} at {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()
+ {
+ // 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()
+ {
+ 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_keeping_the_most_letters_that_fit()
+ {
+ // 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("…", block.Lines[^1]);
+ Assert.True(block.Inside(zone));
+ Assert.True(string.Concat(block.Lines).Length > "Trim tran…".Length);
+ }
+
+ // ───────────────────────── 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.Text.Inside(l.Zone)));
+ }
+
+ [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.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
+ }
+
+ [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);
+
+ // 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.6), 1);
+ var layout = Layout();
+ 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)));
+ // 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 (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);
+ 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_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()
+ {
+ // 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, 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();
+ 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, 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.
+ 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_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);
+ 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(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 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 =
+ ["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();
+
+ // 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()
+ {
+ 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_name_beside_an_icon_is_a_stub_and_Auto_never_mixes(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}";
+ // 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] is { Kind: SliceLabelKind.IconAndText, Text: { Whole: false } text })
+ Assert.True(KeepsMinKept(text, names[i]), $"{where}: \"{string.Join(" / ", text.Lines)}\"");
+ }
+ }
+
+ [AvaloniaFact]
+ public void Captions_show_where_they_fit_beside_icons_too_and_never_make_the_names_smaller()
+ {
+ var layout = Layout();
+ // 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 Icon_only_shows_no_caption_even_one_a_slice_needs()
+ {
+ 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 })
+ 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, 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.
+ 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, ring: true);
+ 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_keeps_the_names_large_rather_than_shrink_them_for_icons()
+ {
+ // 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_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++)
+ 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]
+ 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/SliceLabelReadabilityTests.cs b/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs
new file mode 100644
index 000000000..7889f1532
--- /dev/null
+++ b/tests/Filee.App.Tests/SliceLabelReadabilityTests.cs
@@ -0,0 +1,248 @@
+// 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 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. 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.
+
+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)];
+
+ // 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
+ {
+ Sweep(language, null, Settings, problems);
+ }
+ finally
+ {
+ TestServices.EnsureInitialized("en");
+ }
+ Report(problems, $"{nameof(Every_label_is_the_most_readable_that_fits_in_every_script)}-{language}");
+ }
+
+ [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, 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, groupRing: true);
+ }
+ }
+ }
+
+ private static void Check(SliceLabelLayout layout, IReadOnlyList ring, IReadOnlyList items,
+ 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 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, 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)
+ {
+ 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);
+ 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, 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)
+ {
+ 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 (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");
+ 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 (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, bool groupRing)
+ {
+ 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;
+ }
+ // 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 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
new file mode 100644
index 000000000..3c6c11fa2
--- /dev/null
+++ b/tests/Filee.App.Tests/ToolbarLabelSettingsTests.cs
@@ -0,0 +1,89 @@
+// 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);
+ vm.EditingBack = false;
+ Assert.Null(vm.DonutItems.Single(i => i.Id == "to-png").IconKey); // a format conversion, on the front
+ }
+ finally
+ {
+ crop.Show = before;
+ }
+ }
+}
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));
+}
diff --git a/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs b/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs
new file mode 100644
index 000000000..741d84b74
--- /dev/null
+++ b/tests/Filee.Core.Tests/SliceLabelSettingsTests.cs
@@ -0,0 +1,153 @@
+// 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 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()
+ {
+ var json = JsonSerializer.Serialize(new List { new() { Id = "p" } }, FileeJsonContext.Default.ListPreset);
+ 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);
+ 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));
+ }
+}