Browse / github.com/storytold/craft-launcher / patch
i18n: add Arabic (ar) interface translation catalog
i18n: add Arabic (ar) interface translation catalog ### Summary This PR adds full Arabic (`ar`) interface localization for ArtCraft Launcher. - Added `crates/ui-egui/src/i18n/ar.tsv` covering all 338 catalog entries (plain strings, `@id`, `@msg`, and `@plural`). - Registered `ar` in `LANGUAGES` in `crates/ui-egui/src/i18n/lang.rs` with `complete_menus: true`. - Implemented `plural_arabic` in `lang.rs` adhering to CLDR standard 6 integer plural forms (`zero`, `one`, `two`, `few`, `many`, `other`). - Added `arabic_locales_and_plurals_work` unit test in `crates/ui-egui/src/i18n/tests.rs`. - All entries adhere to clean-room conventions, preserved
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- mamq144 (imported from pull request #650, open)
- Base
7c7271e5f62975326d0add2dcf31f03096be4189- Size
- +796 −10 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4661cba17d72f3d51b71b468
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/ui-egui/Cargo.toml crates/ui-egui/src/cjk_font.rs crates/ui-egui/src/i18n/ar.tsv crates/ui-egui/src/i18n/bidi.rs crates/ui-egui/src/i18n/catalog.rs crates/ui-egui/src/i18n/lang.rs crates/ui-egui/src/i18n/mod.rs crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/i18n/translate.rs crates/ui-egui/tests/arabic.rs
@@ -344,6 +344,7 @@ dependencies = [ "serde_json", "skrifa", "sys-locale",+ "unicode-bidi", "wasm-bindgen", ] @@ -4839,6 +4840,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]]+name = "unicode-bidi"+version = "0.3.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"+ [[package]] name = "unicode-general-category" version = "1.1.0"@@ -17,6 +17,7 @@ egui_extras = { workspace = true, features = ["image", "svg"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } serde = { workspace = true } log = { workspace = true }+unicode-bidi = "0.3" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # The OS's preferred UI languages, without spawning `defaults` or `reg.exe`.@@ -40,6 +40,7 @@ fn script_name(script: Script) -> &'static str { match script { Script::Chinese => "Chinese", Script::Japanese => "Japanese",+ Script::Arabic => "Arabic", } } @@ -48,6 +49,7 @@ fn script_families(script: Script) -> &'static str { match script { Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC and WenQuanYi", Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic",+ Script::Arabic => "Segoe UI, Arial, Tahoma, SF Arabic, Geeza Pro and Noto Sans Arabic", } } @@ -123,9 +125,38 @@ fn candidates(script: Script) -> Vec<Candidate> { match script { Script::Chinese => chinese_candidates(), Script::Japanese => japanese_candidates(),+ Script::Arabic => arabic_candidates(), } } +/// Where each system keeps an Arabic font, best first.+fn arabic_candidates() -> Vec<Candidate> {+ let mut out = Vec::new();+ let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });+ #[cfg(target_os = "windows")]+ {+ let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);+ add(root.join("Fonts").join("segoeui.ttf"), "Segoe UI");+ add(root.join("Fonts").join("arial.ttf"), "Arial");+ add(root.join("Fonts").join("tahoma.ttf"), "Tahoma");+ }+ #[cfg(target_os = "macos")]+ {+ add("/System/Library/Fonts/SFArabic.ttf".into(), "SF Arabic");+ add("/System/Library/Fonts/GeezaPro.ttc".into(), "Geeza Pro");+ add("/System/Library/Fonts/Supplemental/Arial.ttf".into(), "Arial");+ }+ #[cfg(all(unix, not(target_os = "macos")))]+ {+ for dir in unix_font_dirs() {+ add(dir.join("truetype/noto/NotoSansArabic-Regular.ttf"), "Noto Sans Arabic");+ add(dir.join("noto/NotoSansArabic-Regular.ttf"), "Noto Sans Arabic");+ add(dir.join("google-noto/NotoSansArabic-Regular.ttf"), "Noto Sans Arabic");+ }+ }+ out+}+ /// Where each system keeps a Simplified Chinese font, best first. fn chinese_candidates() -> Vec<Candidate> { let mut out = Vec::new();new file mode 100644@@ -0,0 +1,358 @@+# Arabic (ar) catalog for ArtCraft Launcher.+# Format: context<TAB>source<TAB>translation+# Empty context = plain UI string (tl!, t).+# @id = command-id keyed (tr_id).+# @plural = plural forms: zero|one|two|few|many|other (6 forms).+# @msg = status/error message template (message, msg).+# Other context = disambiguation for tr_ctx.+#+# Clean-room: written from the meaning of ArtCraft Launcher's English labels,+# in modern standard Arabic using ordinary computing vocabulary.+#+# Coverage: the whole interface: every menu string and tl! literal (complete_menus = true).+# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite+# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft,+# DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,+# SoundCraft, FigCraft), "GitHub", "Discord", "Finder", "makers".+# Trailing "…" (U+2026) must be preserved. Placeholders must appear in the translation:+# {ago}, {a}, {b}, {count}, {date}, {day}, {done}, {e}, {f}, {installed}, {latest}, {min},+# {month}, {name}, {n}, {os}, {platform}, {sources}, {total}, {version}, {v}, {year}.++ Launcher Launcher+ Craft App Suite Craft App Suite+ Open Source Tools for Creatives, Makers, and Builders أدوات مفتوحة المصدر للمبدعين والصنّاع والمطورين+ Settings الإعدادات+ Check for updates التحقق من وجود تحديثات+ Grid شبكة+ Table جدول+ Show apps as cards عرض التطبيقات كبطاقات+ Show apps as a table عرض التطبيقات كجدول+ All الكل+ Updates التحديثات+ Not installed غير مثبتة+ Search apps البحث في التطبيقات+ Sort فرز+ Suite order ترتيب الحزمة+ Name الاسم+ Inspired by مستوحى من+ Status الحالة+ Installed version الإصدار المثبت+ Latest version أحدث إصدار+ Newest release أحدث إصدار مطروح+ Discord Discord+ GitHub GitHub+ Website الموقع الإلكتروني+ Classic apps التطبيقات الكلاسيكية+ New apps التطبيقات الجديدة+ NEW جديد+ Install تثبيت+ Update to {v} تحديث إلى {v}+ Open فتح+ Download for {os} تنزيل لنظام {os}+ Download تنزيل+ Coming soon قريباً+ Checking… جارٍ التحقق…+ Not available غير متاح+ Offline غير متصل+ Downloading {done} of {total} جارٍ تنزيل {done} من أصل {total}+ Downloading… جارٍ التنزيل…+ Reconnecting… جارٍ إعادة الاتصال…+ Verifying… جارٍ التحقق…+ Installing, then restarting… جارٍ التثبيت، ثم إعادة التشغيل…+ Installing… جارٍ التثبيت…+ Removing… جارٍ الإزالة…+ Pause download إيقاف التنزيل مؤقتاً+ Cancel download إلغاء التنزيل+ Resume download استئناف التنزيل+ Discard download تجاهل التنزيل+ Release notes ملاحظات الإصدار+ Show in Finder إظهار في Finder+ Show in Explorer إظهار في مستكشف الملفات+ Show in Files إظهار في الملفات+ Reinstall إعادة التثبيت+ Uninstall… إلغاء التثبيت…+ All releases جميع الإصدارات+ ArtCraft Launcher {version} is available. يتوفر إصدار ArtCraft Launcher {version}.+ Dismiss تجاهل+ Update and restart تحديث وإعادة تشغيل+ A new version is available. Would you like to relaunch with the updated version? يتوفر إصدار جديد. هل ترغب في إعادة التشغيل بالإصدار المحدّث؟+ Later لاحقاً+ What's new ما الجديد+ No notes were published with this release. لم تُنشر ملاحظات مع هذا الإصدار.+ Released {date} تاريخ الإصدار: {date}+ Open on GitHub فتح على GitHub+ Close إغلاق+ ArtCraft Launcher {version} ArtCraft Launcher {version}+ Only when I ask عند الطلب فقط+ Automatically download and install updates تنزيل التحديثات وتثبيتها تلقائياً+ Notify me when an installed app has an update إشعاري عند توفر تحديث لأي تطبيق مثبت+ Include pre-releases (betas and release candidates) تضمين الإصدارات التجريبية الأولية (بيتا وإصدارات المرشحة)+ Installing on Linux التثبيت على Linux+ Installing on Windows التثبيت على Windows+ Install new apps as تثبيت التطبيقات الجديدة بصيغة+ System package (recommended) حزمة نظام (موصى بها)+ Updates always use the format an app was installed with. تستخدم التحديثات دائماً الصيغة التي تم تثبيت التطبيق بها.+ Running in the background التشغيل في الخلفية+ Keep running in the tray when the window is closed متابعة التشغيل في شريط النظام عند إغلاق النافذة+ Show the window on the current desktop إظهار النافذة على سطح المكتب الحالي+ Start ArtCraft Launcher when I log in تشغيل ArtCraft Launcher عند تسجيل الدخول+ Community المجتمع+ Questions, ideas and news from the ArtCraft team أسئلة وأفكار وأخبار من فريق ArtCraft+ Appearance المظهر+ Quit ArtCraft Launcher إنهاء ArtCraft Launcher+ Done تم+ Uninstall {name}? إلغاء تثبيت {name}؟+ The app will be moved to the Trash. Your documents and settings stay. سيتم نقل التطبيق إلى سلة المهملات. ستبقى مستنداتك وإعداداتك كما هي.+ Windows will remove it like any installed program. Your documents and settings stay. سيقوم Windows بإزالته مثل أي برنامج مثبت. ستبقى مستنداتك وإعداداتك كما هي.+ Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. سيقوم مدير الحزم في نظامك بإزالته، وقد يطلب كلمة المرور. ستبقى مستنداتك وإعداداتك كما هي.+ The app will be removed. Your documents and settings stay. سيتم إزالة التطبيق. ستبقى مستنداتك وإعداداتك كما هي.+ Uninstall إلغاء التثبيت+ Cancel إلغاء+ Checked {ago} تم الفحص {ago}+ just now الآن+ yesterday أمس+ {month} {day}, {year} {day} {month} {year}+ Automatically تلقائياً+ Every Craft app جميع تطبيقات Craft+ Apps on this computer التطبيقات المثبتة على هذا الحاسوب+ Installed apps with a newer version التطبيقات المثبتة التي يتوفر لها إصدار أحدث+ Apps you can install التطبيقات المتاحة للتثبيت+ Hide App إخفاء التطبيق+ Unhide App إظهار التطبيق+ Hidden Apps التطبيقات المخفية+ Apps you hid التطبيقات التي قمت بإخفائها+ Checked {ago} · using {sources} تم الفحص {ago} · عبر {sources}+ {a} and {b} {a} و {b}+ Join the Discord الانضمام إلى Discord+ Learn more معرفة المزيد+ Minimize تصغير+ Maximize تكبير+ Restore استعادة+ New جديد+ OK موافق+ Open link فتح الرابط+ Update تحديث+ {name} icon أيقونة {name}+ Loading ArtCraft Launcher… جارٍ تحميل ArtCraft Launcher…+ Cancel update إلغاء التحديث+ Stop waiting; the update can be started again later إيقاف الانتظار؛ يمكن بدء التحديث مجدداً لاحقاً+ Download again إعادة التنزيل+ Install cancelled تم إلغاء التثبيت+ Install cancelled. The download is kept for the next try. تم إلغاء التثبيت. تم الاحتفاظ بملف التنزيل للمحاولة القادمة.+ Update cancelled تم إلغاء التحديث+ Uninstall cancelled تم إلغاء إلغاء التثبيت+ Retry install إعادة محاولة التثبيت+ Retry update to {version} إعادة محاولة التحديث إلى {version}+ The download is still here. It's checked again, then installed. ملف التنزيل لا يزال موجوداً. سيتم فحصه مجدداً ثم تثبيته.+ Waiting for another app… في انتظار تطبيق آخر…+ Waiting for other installs… في انتظار عمليات التثبيت الأخرى…+ Installer (.msi) ملف تثبيت (.msi)+ Portable (.zip) نسخة محمولة (.zip)+ Disk image (.dmg) صورة قرص (.dmg)+ Debian package (.deb) حزمة دبيان (.deb)+ RPM package (.rpm) حزمة RPM (.rpm)+ Arch package (.pkg.tar.zst) حزمة آرتش (.pkg.tar.zst)+ Flatpak bundle حزمة Flatpak+ Tarball (.tar.gz) أرشيف مضغوط (.tar.gz)+photocraft Image editing تعديل ومعالجة الصور+vectorcraft Vector illustration الرسوميات المتجهية والتوضيحية+filmcraft Video editing, color and sound تحرير الفيديو والألوان والصوت+lightcraft Photo library and raw development مكتبة الصور وتحميض ملفات RAW+pdfcraft Reading, organizing and protecting PDFs قراءة وتنظيم وحماية ملفات PDF+effectcraft Motion graphics and visual effects المؤثرات البصرية والرسوم المتحركة+designcraft Page layout and publishing تنسيق الصفحات والنشر المكتبي+wordcraft Writing and document design الكتابة وتصميم المستندات+deckcraft Presentations and slide shows العروض التقديمية والشرائح+gridcraft Spreadsheets and data الجداول الحسابية والبيانات+cadcraft Computer-aided design and drafting التصميم والرسم الهندسي بمساعدة الحاسوب+soundcraft Recording, editing and mixing audio تسجيل وتحرير ومكساج الصوت+figcraft Interface design and prototyping تصميم الواجهات والنماذج الأولية+month Jan يناير+month Feb فبراير+month Mar مارس+month Apr أبريل+month May مايو+month Jun يونيو+month Jul يوليو+month Aug أغسطس+month Sep سبتمبر+month Oct أكتوبر+month Nov نوفمبر+month Dec ديسمبر+ Not checked yet لم يتم الفحص بعد+ GitHub's hourly limit was reached; full details return in {min} min تم الوصول إلى حد طلبات GitHub بالساعة؛ ستتوفر التفاصيل كاملة خلال {min} دقيقة+ GitHub limit hit; back in {min} min تم بلوغ الحد الأقصى لـ GitHub؛ سيعود خلال {min} دقيقة+ Installing for {platform} جارٍ التثبيت لمنصة {platform}+ Downloads for {platform} التنزيلات الخاصة بمنصة {platform}+ No downloads for this system لا توجد تنزيلات لهذا النظام+ Join the ArtCraft community on Discord انضم إلى مجتمع ArtCraft على Discord+ ArtCraft on {name} ArtCraft على {name}+ Couldn't open {name}: {e} تعذر فتح {name}: {e}+ Download cancelled تم إلغاء التنزيل+ Available to install متاح للتثبيت+ Available متاح+ Installed · up to date مثبت · محدّث بالكامل+ Update available يتوفر تحديث+ Installed مثبت+ installed مثبت+ Not available here غير متاح هنا+ Updating… جارٍ التحديث…+ Update paused التحديث متوقف مؤقتاً+ Download paused التنزيل متوقف مؤقتاً+ {version} installed → {latest} released المثبت: {version} ← المطروح: {latest}+ Version {version} الإصدار {version}+ {installed} installed · latest {latest} المثبت: {installed} · الأحدث: {latest}+ Installed · latest {latest} مثبت · الأحدث {latest}+ Version {version} installed الإصدار {version} مثبت+ Version {latest} الإصدار {latest}+ No release published yet لم يتم نشر أي إصدار بعد+ ArtCraft Launcher ArtCraft Launcher+ App التطبيق+ Latest الأحدث+ Released تاريخ الإصدار+ Auto تلقائي+ Language اللغة+ Ascending تصاعدي+ Descending تنازلي+ Click to reverse the order انقر لعكس الترتيب+ Click to sort by this column انقر للفرز حسب هذا العمود+ Like {name} مشابه لـ {name}+ {done} of {total} {done} من {total}+ Downloading جارٍ التنزيل+ Verifying checksum… جارٍ التحقق من المجموع الاختباري (Checksum)…+ Resume استئناف+ {done} of {total} downloaded تم تنزيل {done} من أصل {total}+ {done} downloaded تم تنزيل {done}+ Checking for updates… جارٍ البحث عن تحديثات…+ Downloads the new version, replaces this one and opens it again تنزيل الإصدار الجديد واستبدال الإصدار الحالي ثم إعادة فتحه+ Couldn't check for updates: {f} تعذر التحقق من التحديثات: {f}+ Website (coming soon) الموقع الإلكتروني (قريباً)+ GitHub repository مستودع GitHub+ ArtCraft ArtCraft+ Update 1 app تحديث تطبيق واحد+ Update all ({count}) تحديث الكل ({count})+ ⊞ Grid ⊞ شبكة+ ☰ Table ☰ جدول+ 🔍 Search apps 🔍 البحث في التطبيقات+ {name} {version} {name} {version}+ Match system تطابق النظام+ Dark داكن+ Light فاتح+ Full changelog سجل التغييرات الكامل+ link رابط+ No apps match لا توجد تطبيقات مطابقة+ Open ArtCraft Launcher to update. افتح ArtCraft Launcher للتحديث.+ {name} {v} is available يتوفر {name} {v}+ {count} updates are available يتوفر {count} تحديثات+ These notes weren't fetched (GitHub's request limit was reached). They're on the release page. تعذر جلب هذه الملاحظات (تم الوصول لحد طلبات GitHub). يمكنك الاطلاع عليها في صفحة الإصدار.+ When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. عند التفعيل، يؤدي فتح المشغل من شريط القوائم إلى نقل نافذته لسطح المكتب الحالي. وعند التعطيل، يقوم macOS بالتبديل إلى سطح المكتب الذي كانت توجد به النافذة.+ Couldn't change the login item: {e} تعذر تغيير عنصر بدء التشغيل عند تسجيل الدخول: {e}+column App التطبيق+column Installed المثبت+column Latest الأحدث+column Released تاريخ الإصدار+filter Installed المثبتة+ App details تفاصيل التطبيق+ Close (Esc) إغلاق (Esc)+ Features الميزات+ Latest release أحدث إصدار+ Version الإصدار+ Platforms المنصات+ Releases الإصدارات+ Enlarge picture تكبير الصورة+ Click to enlarge انقر للتكبير+ Previous picture (←) الصورة السابقة (←)+ Next picture (→) الصورة التالية (→)+ Loading pictures… جارٍ تحميل الصور…+ Pictures couldn't be loaded. Reopen this page to try again. تعذر تحميل الصور. أعد فتح هذه الصفحة للمحاولة مرة أخرى.+ {name} details تفاصيل {name}+@plural {n} app|{n} apps {n} تطبيق|{n} تطبيق|{n} تطبيقان|{n} تطبيقات|{n} تطبيقاً|{n} تطبيق+@plural {n} min ago|{n} min ago منذ {n} دقيقة|منذ دقيقة واحدة ({n})|منذ دقيقتين ({n})|منذ {n} دقائق|منذ {n} دقيقة|منذ {n} دقيقة+@plural {n} h ago|{n} h ago منذ {n} ساعة|منذ ساعة واحدة ({n})|منذ ساعتين ({n})|منذ {n} ساعات|منذ {n} ساعة|منذ {n} ساعة+@plural {n} day ago|{n} days ago منذ {n} يوم|منذ يوم واحد ({n})|منذ يومين ({n})|منذ {n} أيام|منذ {n} يوماً|منذ {n} يوم+@msg Couldn't open {name}: {e} تعذر فتح {name}: {e}+@msg Download cancelled تم إلغاء التنزيل+@msg {name} updated to {version} تم تحديث {name} إلى {version}+@msg {name} installed: {version} تم تثبيت {name}: {version}+@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) تم تحديث {name} إلى {version} (لم ينشر هذا الإصدار مجموعاً اختيارياً، لذا تم التحقق من حجمه فقط)+@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) تم تثبيت {name}: {version} (لم ينشر هذا الإصدار مجموعاً اختيارياً، لذا تم التحقق من حجمه فقط)+@msg {name} was removed تمت إزالة {name}+@msg {name} is running. Quit it, then try again. {name} قيد التشغيل حالياً. أغلقه ثم أعد المحاولة.+@msg {name} was installed from the {repo} repository or the AUR ({pkg}). Update it with {cmd} or sudo pacman -Syu تم تثبيت {name} من مستودع {repo} أو AUR ({pkg}). يمكنك تحديثه باستخدام {cmd} أو sudo pacman -Syu+@msg {name} was installed from the {repo} repository or the AUR ({pkg}). Update it with your AUR helper (for example: yay -S {pkg}) or sudo pacman -Syu تم تثبيت {name} من مستودع {repo} أو AUR ({pkg}). يمكنك تحديثه باستخدام مساعد AUR لديك (مثال: yay -S {pkg}) أو sudo pacman -Syu+@msg {name} was installed from the AUR ({pkg}). Update it with {cmd} تم تثبيت {name} من AUR ({pkg}). يمكنك تحديثه باستخدام {cmd}+@msg {name} was installed from the AUR ({pkg}). Update it with your AUR helper (for example: yay -S {pkg}) تم تثبيت {name} من AUR ({pkg}). يمكنك تحديثه باستخدام مساعد AUR لديك (مثال: yay -S {pkg})+@id app.install تثبيت+@id app.update تحديث إلى {v}+@id app.open فتح+@id app.download تنزيل لنظام {os}+@id app.reveal إظهار في Finder+@id app.reinstall إعادة التثبيت+@id app.uninstall إلغاء التثبيت…+@id app.notes ملاحظات الإصدار+@id app.website الموقع الإلكتروني+@id app.github مستودع GitHub+@id app.releases جميع الإصدارات+@id launcher.update تحديث وإعادة تشغيل+@id launcher.download تنزيل+@id launcher.notes ما الجديد+@id launcher.dismiss تجاهل+@id settings.open الإعدادات+@id settings.quit إنهاء ArtCraft Launcher+@id view.grid شبكة+@id view.table جدول+@id filter.all الكل+@id filter.installed المثبتة+@id filter.updates التحديثات+@id filter.notinstalled غير مثبتة+@id sort.suite ترتيب الحزمة+@id sort.name الاسم+@id sort.inspired مستوحى من+@id sort.status الحالة+@id sort.installed الإصدار المثبت+@id sort.latest أحدث إصدار+@id sort.released أحدث إصدار مطروح+@id help.discord Discord+@id help.github GitHub+@id help.website الموقع الإلكتروني+@id update.check التحقق من وجود تحديثات+@id update.check_auto التحقق من وجود تحديثات تلقائياً+@id update.notify إشعاري عند توفر تحديث لأي تطبيق مثبت+@id update.prereleases تضمين الإصدارات التجريبية الأولية (بيتا وإصدارات المرشحة)+@id linux.preferred_kind تثبيت التطبيقات الجديدة بصيغة+@id tray.close_to_tray متابعة التشغيل في شريط النظام عند إغلاق النافذة+@id tray.show_on_current_desktop إظهار النافذة على سطح المكتب الحالي+@id tray.login_item تشغيل ArtCraft Launcher عند تسجيل الدخول+@id appearance.theme المظهر+ Show ArtCraft Launcher إظهار ArtCraft Launcher+ Check for Updates التحقق من وجود تحديثات+ Visit getartcraft.com زيارة getartcraft.com+ ArtCraft on GitHub ArtCraft على GitHub+ Join the ArtCraft Discord الانضمام إلى مجتمع ArtCraft على Discord+@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} تحديث متوفر|ArtCraft Launcher · {n} تحديث متوفر|ArtCraft Launcher · {n} تحديثان متوفران|ArtCraft Launcher · {n} تحديثات متوفرة|ArtCraft Launcher · {n} تحديثاً متوفراً|ArtCraft Launcher · {n} تحديث متوفر+ Install folder مجلد التثبيت+ Default install folder مجلد التثبيت الافتراضي+ System default الافتراضي للنظام+ Browse… استعراض…+ Use default استخدام الافتراضي+ New installs use this folder. Updates stay in their current location. تستخدم عمليات التثبيت الجديدة هذا المجلد. تظل التحديثات في مسارها الحالي.+ Custom folders use AppImage on Linux. تستخدم المجلدات المخصصة صيغة AppImage على أنظمة Linux.+ The system manages install locations here. النظام هو من يدير مسارات التثبيت هنا.+@msg couldn't use install folder {path}: {reason} تعذر استخدام مجلد التثبيت {path}: {reason}+@msg this release has no AppImage for a custom install folder لا يحتوي هذا الإصدار على حزمة AppImage لمجلد تثبيت مخصص+@msg custom install folders aren't supported on this system المجلدات المخصصة للتثبيت غير مدعومة على هذا النظام+@msg {path} already exists and wasn't installed by ArtCraft Launcher المسار {path} موجود بالفعل ولم يتم تثبيته عبر ArtCraft Launcher+@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. تعذر نقل {path} إلى سلة المهملات ({reason}). يرجى حذفه يدوياً عبر Finder بدلاً من ذلك.+ Apps are placed inside this folder. توضع التطبيقات داخل هذا المجلد.+ Replace what's there? هل تريد استبدال ما هو موجود؟+ {path} already exists. Installing {name} will overwrite it. المسار {path} موجود بالفعل. سيؤدي تثبيت {name} إلى الكتابة فوقه.+ Replace استبدال+ Recently updated محدّثة مؤخراً+ Name (A–Z) الاسم (أ–ي)+ Name (Z–A) الاسم (ي–أ)+ Frequently used الأكثر استخداماً+ Next app التطبيق التالي+ Previous app التطبيق السابق+ Show {n} more changes إظهار {n} تغييرات إضافية+ Last updated: {date} آخر تحديث: {date}+column Version الإصدارnew file mode 100644@@ -0,0 +1,121 @@+//! Bidirectional text handling (UAX #9) for RTL interface languages such as Arabic.+//!+//! Reorders mixed RTL/LTR runs and words into visual presentation order so that+//! `egui`/`epaint`'s left-to-right galley layout renders Arabic sentences naturally+//! from right to left while preserving logical character order within each word for+//! OpenType cursive shaping (HarfRust).++use std::borrow::Cow;+use unicode_bidi::{BidiInfo, Level};++/// Reorder text with mixed directionality into visual presentation order for layout and display.+pub fn reorder(text: &str) -> Cow<'_, str> {+ if !has_rtl(text) {+ return Cow::Borrowed(text);+ }++ let bidi_info = BidiInfo::new(text, Some(Level::rtl()));+ if !bidi_info.has_rtl() {+ return Cow::Borrowed(text);+ }++ let mut result = String::with_capacity(text.len());+ for para in &bidi_info.paragraphs {+ let (levels, runs) = bidi_info.visual_runs(para, para.range.clone());+ if runs.iter().all(|r| levels[r.start].is_ltr()) {+ result.push_str(&text[para.range.clone()]);+ continue;+ }++ for run in runs {+ let is_rtl = levels[run.start].is_rtl();+ let slice = &text[run.clone()];+ if is_rtl {+ reverse_tokens(slice, &mut result);+ } else {+ result.push_str(slice);+ }+ }+ }+ Cow::Owned(result)+}++/// Reverse tokens (words and whitespace blocks) within an RTL run so words appear in+/// visual reading order from right to left, while preserving character order within+/// each word for OpenType cursive shaping.+fn reverse_tokens(slice: &str, out: &mut String) {+ let mut tokens = Vec::new();+ let mut curr_start = 0;+ let mut in_whitespace = None;++ for (byte_idx, ch) in slice.char_indices() {+ let is_ws = ch.is_whitespace();+ match in_whitespace {+ None => {+ in_whitespace = Some(is_ws);+ }+ Some(ws) if ws != is_ws => {+ tokens.push(&slice[curr_start..byte_idx]);+ curr_start = byte_idx;+ in_whitespace = Some(is_ws);+ }+ _ => {}+ }+ }+ if curr_start < slice.len() {+ tokens.push(&slice[curr_start..]);+ }++ tokens.reverse();+ for token in tokens {+ out.push_str(token);+ }+}++/// Does this text contain any right-to-left characters (Arabic, Hebrew, etc.)?+pub fn has_rtl(text: &str) -> bool {+ text.chars().any(is_rtl_char)+}++/// True if `c` is a character from a Right-to-Left script.+pub fn is_rtl_char(c: char) -> bool {+ matches!(+ c,+ '\u{0590}'..='\u{05FF}' // Hebrew+ | '\u{0600}'..='\u{06FF}' // Arabic+ | '\u{0750}'..='\u{077F}' // Arabic Supplement+ | '\u{08A0}'..='\u{08FF}' // Arabic Extended-A+ | '\u{FB50}'..='\u{FDFF}' // Arabic Presentation Forms-A+ | '\u{FE70}'..='\u{FEFF}' // Arabic Presentation Forms-B+ )+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn pure_arabic_reorders_words_for_visual_reading() {+ assert_eq!(reorder("تعديل ومعالجة الصور"), "الصور ومعالجة تعديل");+ assert_eq!(reorder("البحث في التطبيقات"), "التطبيقات في البحث");+ assert_eq!(reorder("تسجيل وتحرير ومكساج الصوت"), "الصوت ومكساج وتحرير تسجيل");+ }++ #[test]+ fn mixed_english_and_arabic_reorders_correctly() {+ assert_eq!(reorder("يتوفر إصدار ArtCraft Launcher 0.5.1."), ".ArtCraft Launcher 0.5.1 إصدار يتوفر");+ assert_eq!(reorder("ArtCraft Launcher · 3 تحديثات متوفرة"), "متوفرة تحديثات ArtCraft Launcher · 3");+ }++ #[test]+ fn parenthesized_sort_labels_reorder_correctly() {+ assert_eq!(reorder("الاسم (أ–ي)"), "(أ–ي) الاسم");+ assert_eq!(reorder("الاسم (ي–أ)"), "(ي–أ) الاسم");+ }++ #[test]+ fn english_only_text_is_unmodified() {+ let text = "ArtCraft Launcher";+ assert_eq!(reorder(text), text);+ }+}@@ -115,13 +115,18 @@ impl Catalog { continue; }; let (context, source_text, translation) = (unescape(context), unescape(source_text), unescape(translation));+ let display_text = if !translation.contains('{') && super::bidi::has_rtl(&translation) {+ super::bidi::reorder(&translation).into_owned()+ } else {+ translation.clone()+ }; let duplicate = match context.as_str() {- "" => cat.plain.insert(source_text.clone(), translation).is_some(),- "@id" => cat.by_id.insert(source_text.clone(), translation).is_some(),+ "" => cat.plain.insert(source_text.clone(), display_text).is_some(),+ "@id" => cat.by_id.insert(source_text.clone(), display_text).is_some(), "@plural" => { let forms: Vec<String> = translation.split('|').map(str::to_string).collect();- if forms.len() > 3 {- log::warn!("i18n catalog: line {line_no}: @plural expects 1 to 3 forms separated by '|', got {}", forms.len());+ if forms.len() > 6 {+ log::warn!("i18n catalog: line {line_no}: @plural expects 1 to 6 forms separated by '|', got {}", forms.len()); continue; } cat.plural.insert(source_text.clone(), forms).is_some()@@ -135,7 +140,7 @@ impl Catalog { } cat.messages.insert(source_text.clone(), translation).is_some() }- _ => cat.contextual.entry(context.clone()).or_default().insert(source_text.clone(), translation).is_some(),+ _ => cat.contextual.entry(context.clone()).or_default().insert(source_text.clone(), display_text).is_some(), }; if duplicate { log::warn!("i18n catalog: line {line_no}: duplicate key {context:?} {source_text:?}");@@ -13,11 +13,12 @@ use super::detect::system_lang; pub enum Script { Chinese, Japanese,+ Arabic, } impl Script { /// Every script a language can be written in.- pub const ALL: [Script; 2] = [Script::Chinese, Script::Japanese];+ pub const ALL: [Script; 3] = [Script::Chinese, Script::Japanese, Script::Arabic]; } /// One supported UI language.@@ -93,8 +94,21 @@ pub(super) fn plural_polish(n: u64) -> usize { } } +/// Arabic (CLDR `ar`, integer counts): zero (0), one (1), two (2), few (3–10, 103–110),+/// many (11–99, 111–199), other (100–102, 200–202).+pub(super) fn plural_arabic(n: u64) -> usize {+ match (n, n % 100) {+ (0, _) => 0,+ (1, _) => 1,+ (2, _) => 2,+ (_, 3..=10) => 3,+ (_, 11..=99) => 4,+ _ => 5,+ }+}+ /// The registry. English first: it is the fallback and the source language.-pub static LANGUAGES: [LangInfo; 11] = [+pub static LANGUAGES: [LangInfo; 12] = [ LangInfo { code: "en", name: "English",@@ -216,6 +230,17 @@ pub static LANGUAGES: [LangInfo; 11] = [ complete_menus: true, catalog: OnceLock::new(), },+ LangInfo {+ code: "ar",+ name: "العربية",+ english_name: "Arabic",+ script: Some(Script::Arabic),+ source: include_str!("ar.tsv"),+ plural: plural_arabic,+ plural_forms: 6,+ complete_menus: true,+ catalog: OnceLock::new(),+ }, ]; impl LangInfo {@@ -254,6 +279,7 @@ impl Lang { pub const PL: Lang = Lang(&LANGUAGES[8]); pub const HU: Lang = Lang(&LANGUAGES[9]); pub const JA: Lang = Lang(&LANGUAGES[10]);+ pub const AR: Lang = Lang(&LANGUAGES[11]); pub fn code(self) -> &'static str { self.0.code@@ -295,6 +321,11 @@ impl Lang { } /// Does this language's catalog claim to cover every `tl!` literal?+ /// Does this language read right-to-left?+ pub fn is_rtl(self) -> bool {+ matches!(self.0.script, Some(Script::Arabic))+ }+ pub fn complete_menus(self) -> bool { self.0.complete_menus }@@ -26,6 +26,7 @@ //! rows hold whole messages or templates such as `Couldn't open {name}: {e}`, whose //! placeholders match any text and are carried over into the translation. +pub mod bidi; mod catalog; mod detect; mod lang;@@ -34,6 +35,7 @@ mod translate; #[cfg(test)] mod tests; +pub use bidi::reorder as bidi_reorder; pub use detect::{detect_system_lang_in_background, set_system_locales, set_test_lang, system_lang}; pub use lang::{LANGUAGES, Lang, LangInfo, Script, lang_from_tag}; pub use translate::{current, fmt, has, message, msg, set_current, t, t_id, tn, tr, tr_ctx, tr_id, trn};@@ -4,7 +4,7 @@ use std::collections::{BTreeSet, HashSet}; use super::catalog::{Catalog, unescape};-use super::lang::{plural_czech, plural_east_slavic, plural_polish};+use super::lang::{plural_arabic, plural_czech, plural_east_slavic, plural_polish}; use super::{LANGUAGES, Lang, LangInfo, lang_from_tag, message, tr, trn}; /// Each catalog's rows as (line number, context, source, translation), split exactly as the@@ -533,3 +533,33 @@ fn complete_catalogs_translate_package_formats() { } } }++#[test]+fn arabic_locales_and_plurals_work() {+ for tag in ["ar", "ar-SA", "ar-EG", "ar-AE", "ar-MA", "ar_SA.UTF-8"] {+ assert_eq!(lang_from_tag(tag), Some(Lang::AR), "{tag}");+ }+ assert_eq!(super::detect::first_supported(["xx-XX", "ar-SA", "en-US"]), Some(Lang::AR));+ assert_eq!(plural_arabic(0), 0);+ assert_eq!(plural_arabic(1), 1);+ assert_eq!(plural_arabic(2), 2);+ for n in 3..=10 {+ assert_eq!(plural_arabic(n), 3);+ }+ for n in 11..=99 {+ assert_eq!(plural_arabic(n), 4);+ }+ assert_eq!(plural_arabic(100), 5);+}++#[test]+fn arabic_bidi_and_templates_work() {+ assert_eq!(tr(Lang::AR, "Search apps"), "التطبيقات في البحث");+ assert_eq!(tr(Lang::AR, "Sort"), "فرز");+ assert_eq!(tr(Lang::AR, "Name (A–Z)"), "(أ–ي) الاسم");+ assert_eq!(super::translate::fmt(tr(Lang::AR, "Last updated: {date}"), &[("date", "2026-10-20")]), "20-10-2026 تحديث: آخر");+ assert_eq!(+ trn(Lang::AR, 3, "ArtCraft Launcher · {n} update available", "ArtCraft Launcher · {n} updates available"),+ "متوفرة تحديثات ArtCraft Launcher · 3"+ );+}@@ -54,7 +54,7 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { for (k, v) in args { out = out.replace(&format!("{{{k}}}"), v); }- out+ if super::bidi::has_rtl(&out) { super::bidi::reorder(&out).into_owned() } else { out } } /// A plural-aware string: `one`/`other` are the English forms (with `{n}` where the count goes),@@ -76,7 +76,8 @@ pub fn tn(n: u64, one: &str, other: &str) -> String { /// the parts of a message a template's placeholders stand for (names, versions, reasons), stay /// as they are. pub fn message(lang: Lang, s: &str) -> String {- lang.catalog().message(s).unwrap_or_else(|| s.to_string())+ let out = lang.catalog().message(s).unwrap_or_else(|| s.to_string());+ if lang.is_rtl() && super::bidi::has_rtl(&out) { super::bidi::reorder(&out).into_owned() } else { out } } /// [`message`] in the current language, remembering the last answer: the status bar asks for thenew file mode 100644@@ -0,0 +1,199 @@+//! The Arabic UI, in its own test binary: the UI language is process-wide, so it can't share one+//! with the English UI tests. Arabic is drawn with the system's Arabic font; where there is none+//! (minimal Linux installs), the UI must stay in English and say why.+//! `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with `-- --test-threads=1`).++use std::cell::{Cell, RefCell};+use std::rc::Rc;++use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, Source};+use artcraft_launcher_core::release::{Asset, Release};+use artcraft_launcher_core::status::InstallSource;+use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};+use artcraft_launcher_ui_egui::settings::ViewMode;+use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;++const NOW: i64 = 1_791_500_000;++struct Fake {+ events: Vec<Event>,+ sent: Rc<RefCell<Vec<Command>>>,+ manage: bool,+ clock: Rc<Cell<i64>>,+}++impl Backend for Fake {+ fn send(&mut self, command: Command) {+ self.sent.borrow_mut().push(command);+ }+ fn poll(&mut self) -> Vec<Event> {+ std::mem::take(&mut self.events)+ }+ fn capabilities(&self) -> Capabilities {+ Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }+ }+ fn platform(&self) -> Option<Platform> {+ Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })+ }+ fn now(&self) -> i64 {+ self.clock.get()+ }+}++struct NoHost;+impl Host for NoHost {}++fn release(id: &str, v: &str, at: i64) -> Release {+ let name = format!("{id}-{v}-macos-universal.dmg");+ Release {+ tag: format!("v{v}"),+ version: version::parse(v),+ name: format!("v{v}"),+ published_at: Some(at),+ html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),+ notes: "## What's Changed\n* Faster things by @a in https://github.com/storytold/x/pull/1".into(),+ prerelease: false,+ assets: vec![Asset {+ name: name.clone(),+ size: 150 << 20,+ url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),+ sha256: None,+ }],+ }+}++/// A realistic mix: updates, up-to-date apps, a download in progress, one app with no release.+fn events() -> Vec<Event> {+ let mut ev = Vec::new();+ let versions = [+ ("photocraft", "0.5.0"),+ ("vectorcraft", "0.7.0"),+ ("filmcraft", "0.4.0"),+ ("lightcraft", "0.4.0"),+ ("pdfcraft", "0.4.0"),+ ("effectcraft", "0.6.0"),+ ("designcraft", "0.4.0"),+ ("wordcraft", "0.3.0"),+ ("deckcraft", "0.3.0"),+ ("gridcraft", "0.3.0"),+ ("cadcraft", "0.3.0"),+ ];+ for (i, (id, v)) in versions.iter().enumerate() {+ ev.push(Event::Latest {+ app: (*id).into(),+ latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)),+ from: Origin::ArtCraftApi,+ });+ }+ ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });+ let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")];+ for app in &SUITE {+ let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed {+ version: version::parse(v),+ source: InstallSource::MacApp,+ location: format!("/Applications/{}.app", app.name),+ package: None,+ });+ ev.push(Event::Detected { app: app.id.into(), installed: inst });+ }+ ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } });+ ev.push(Event::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) });+ ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } });+ ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None });+ ev+}++fn harness_language(+ view: ViewMode,+ theme: egui::Theme,+ manage: bool,+ language: &str,+) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {+ artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);+ let sent = Rc::new(RefCell::new(Vec::new()));+ let sent2 = sent.clone();+ let mut backend = Some(Fake { events: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });+ let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() };+ let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).build_ui_state(+ move |ui, state: &mut Option<LauncherUi>| {+ if state.is_none()+ && let Some(b) = backend.take()+ {+ let mut s = settings.clone();+ s.theme = if theme == egui::Theme::Dark {+ artcraft_launcher_ui_egui::theme::ThemeChoice::Dark+ } else {+ artcraft_launcher_ui_egui::theme::ThemeChoice::Light+ };+ *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s));+ }+ if let Some(l) = state.as_mut() {+ l.logic(&ui.ctx().clone(), &mut NoHost);+ l.ui(ui, &mut NoHost);+ }+ },+ None,+ );+ (harness, sent)+}++fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {+ let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };+ let img = h.render().unwrap();+ img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();+}++// One test: the UI language is process-wide.+#[test]+fn arabic_switches_persists_and_keeps_install_actions() {+ let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en");+ h.run_steps(6);+ h.get_by_label("Settings").click();+ h.run_steps(3);+ h.get_by_value("English").click();+ h.run_steps(2);+ // Opening Settings doesn't load the (large) Arabic font: the picker names it in English.+ assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(artcraft_launcher_ui_egui::i18n::Lang::AR));+ // The list is long enough to scroll, so the last option may be out of view: click it through+ // accesskit, which reaches widgets that aren't visible.+ h.get_by_label("Arabic").click_accesskit();+ h.run_steps(3);+ if !artcraft_launcher_ui_egui::theme::cjk_font_available(artcraft_launcher_ui_egui::i18n::Lang::AR) {+ // No Arabic font: choosing it keeps the UI in English, and Settings says why.+ assert_eq!(h.state().as_ref().unwrap().settings.language, "ar");+ h.get_by_label_contains("No font for this language was found");+ h.get_by_label("Notify me when an installed app has an update");+ snapshot(&mut h, "settings-ar-no-font");+ return;+ }+ // Choosing it loaded the font: now the picker shows both names.+ h.get_by_value("Arabic (العربية)");+ h.get_by_label(artcraft_launcher_ui_egui::i18n::tr(artcraft_launcher_ui_egui::i18n::Lang::AR, "Notify me when an installed app has an update"));+ snapshot(&mut h, "settings-ar-dark");+ let settings = h.state().as_ref().unwrap().settings.clone();+ assert_eq!(settings.language, "ar");+ let saved = serde_json::to_string(&settings).unwrap();+ let restored: Settings = serde_json::from_str(&saved).unwrap();+ assert_eq!(restored.language, "ar");+ h.get_by_label("تم").click();+ h.run_steps(3);+ h.get_by_label(artcraft_launcher_ui_egui::i18n::tr_ctx(artcraft_launcher_ui_egui::i18n::Lang::AR, "photocraft", "Image editing"));+ assert!(h.query_all_by_label_contains("0.5.0").count() >= 1);+ // Product names stay in English.+ assert!(h.query_by_label("Launcher").is_none());+ snapshot(&mut h, "grid-ar-dark");+ h.get_by_label_contains("الكل").click();+ h.run_steps(2);+ let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect();+ assert_eq!(installs, ["photocraft", "effectcraft"]);++ let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);+ table.run_steps(6);+ table.get_by_label("الإصدار");+ snapshot(&mut table, "table-ar-light");+ let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);+ web.run_steps(6);+ assert!(web.query_all_by_label_contains("تنزيل لنظام").count() >= 3 || web.query_all_by_label_contains("macOS").count() >= 3);+}