collective

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

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -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"diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml--- a/crates/ui-egui/Cargo.toml+++ b/crates/ui-egui/Cargo.toml@@ -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`.diff --git a/crates/ui-egui/src/cjk_font.rs b/crates/ui-egui/src/cjk_font.rs--- a/crates/ui-egui/src/cjk_font.rs+++ b/crates/ui-egui/src/cjk_font.rs@@ -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();diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsvnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/i18n/ar.tsv@@ -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	الإصدارdiff --git a/crates/ui-egui/src/i18n/bidi.rs b/crates/ui-egui/src/i18n/bidi.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/i18n/bidi.rs@@ -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);+    }+}diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs--- a/crates/ui-egui/src/i18n/catalog.rs+++ b/crates/ui-egui/src/i18n/catalog.rs@@ -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:?}");diff --git a/crates/ui-egui/src/i18n/lang.rs b/crates/ui-egui/src/i18n/lang.rs--- a/crates/ui-egui/src/i18n/lang.rs+++ b/crates/ui-egui/src/i18n/lang.rs@@ -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     }diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs--- a/crates/ui-egui/src/i18n/mod.rs+++ b/crates/ui-egui/src/i18n/mod.rs@@ -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};diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs--- a/crates/ui-egui/src/i18n/tests.rs+++ b/crates/ui-egui/src/i18n/tests.rs@@ -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"+    );+}diff --git a/crates/ui-egui/src/i18n/translate.rs b/crates/ui-egui/src/i18n/translate.rs--- a/crates/ui-egui/src/i18n/translate.rs+++ b/crates/ui-egui/src/i18n/translate.rs@@ -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 thediff --git a/crates/ui-egui/tests/arabic.rs b/crates/ui-egui/tests/arabic.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/arabic.rs@@ -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);+}