collective

Browse / github.com/storytold/craft-launcher / patch

i18n: Arabic interface with RTL display order

i18n: Arabic interface with RTL display order Adds العربية as an interface language. - Full Arabic catalog (`ar.tsv`) covering the launcher UI - `bidi.rs` (`unicode-bidi`): catalog strings are stored in logical order and rearranged to display order at load (`display_rtl`); user-provided names are rearranged at paint time (`visual`) — stored data is never mutated - Arabic-capable font fallback through the existing font pipeline - Language menu entry + parsing for `ar`, `ar-SA`, `ar_EG`-style tags - i18n test suite extended to cover Arabic (32 tests green) Chrome layout stays LTR — same scope as the existing CJK localizations.

Status
open
Score
0.00
Adopters
0 other operators
Author
NibrasAz7 (imported from pull request #397, open)
Base
d6fdab6596d4246d2572ac1ef4c94ac396b5728c
Size
+749 −10 in 10 files
Review
pending
Shared
2026-10-28
Id
50b6ecc7c28dd9072a64fc2e

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/ui-egui/Cargo.toml crates/ui-egui/src/bidi.rs crates/ui-egui/src/cjk_font.rs crates/ui-egui/src/i18n/ar.tsv 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/lib.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -339,6 +339,7 @@ dependencies = [  "serde_json",  "skrifa",  "sys-locale",+ "unicode-bidi", ]  [[package]]@@ -4768,6 +4769,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,8 @@ egui_extras = { workspace = true, features = ["image", "svg"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } serde = { workspace = true } log = { workspace = true }+# Display order for a right-to-left catalog's strings (`bidi`).+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/bidi.rs b/crates/ui-egui/src/bidi.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/bidi.rs@@ -0,0 +1,199 @@+//! Display order for the labels of a right-to-left interface language (the Arabic catalog).+//!+//! egui shapes each font-face run with the script's own direction, so the letters of an Arabic+//! word join and run right to left, but it places the runs themselves left to right in logical+//! order: `واحد اثنين` would show its words swapped. [`display_rtl`] puts the runs in display+//! order and leaves the letters of each word alone for the shaper.++use unicode_bidi::{BidiInfo, Level, ParagraphInfo};++/// A label of a right-to-left interface language, in logical order, rearranged for egui: each+/// line reads right to left, with Latin words and numbers inside it left to right.+///+/// - `{name}` placeholders stay whole and unmirrored, so [`crate::i18n::fmt`] still fills them.+///   What it inserts is drawn left to right at that place.+/// - Spaces and separator dots at either end stay at that end, next to what the code appends.+/// - A short caption's closing colon stays on the right too, next to the widget it captions.+///+/// Text without right-to-left characters comes back unchanged. This is for painting one line;+/// a label egui wraps breaks at the wrong end.+pub fn display_rtl(text: &str) -> String {+    if !text.chars().any(is_rtl_script) {+        return text.to_owned();+    }+    let mut out = String::with_capacity(text.len());+    for (i, line) in text.split('\n').enumerate() {+        if i > 0 {+            out.push('\n');+        }+        push_rtl_line(&mut out, line);+    }+    out+}++/// `text` in display order for the paragraph direction `base`, appended to `out`.+fn push_visual(out: &mut String, text: &str, base: Level) {+    let info = BidiInfo::new(text, Some(base));+    for para in &info.paragraphs {+        push_line(out, text, &info, para, para.range.clone());+    }+}++/// The `line` of paragraph `para` (a non-empty byte range of `text`) in display order, as egui+/// needs it, appended to `out`.+fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &ParagraphInfo, line: std::ops::Range<usize>) {+    let (levels, runs) = info.visual_runs(para, line);+    // First the true display order, every character left to right as it appears on screen.+    let mut shown = String::with_capacity(text.len());+    for run in runs {+        let rtl = levels.get(run.start).is_some_and(Level::is_rtl);+        // Runs lie on character boundaries; a missing slice is skipped, never a panic.+        let Some(piece) = text.get(run) else { continue };+        let chars = piece.chars().filter(|c| !is_bidi_control(*c));+        if rtl {+            shown.extend(chars.rev().map(mirrored));+        } else {+            shown.extend(chars);+        }+    }+    push_for_shaper(out, &shown);+}++/// `shown` (in screen order) as egui must get it: egui hands each stretch of right-to-left+/// script to the shaper as one run, which lays it out right to left (and joins its letters), so+/// each such stretch goes back to logical order. What lies between them (spaces, punctuation,+/// Latin) is drawn by the Latin face left to right and stays as it is. The stretches include+/// Persian and Arabic-Indic digits: Arabic script to the shaper, though they read left to right.+fn push_for_shaper(out: &mut String, shown: &str) {+    let mut stretch: Vec<char> = Vec::new();+    for c in shown.chars() {+        if is_rtl_script(c) || is_joiner(c) {+            stretch.push(c);+        } else {+            out.extend(stretch.drain(..).rev());+            out.push(c);+        }+    }+    out.extend(stretch.drain(..).rev());+}++/// The most words a caption may have for its closing colon to stay on the right.+const CAPTION_WORDS: usize = 3;+/// First of the private-use characters that stand for placeholders while a line is reordered.+/// They are strong left-to-right, like the names they replace.+const PLACEHOLDER_BASE: u32 = 0xE000;+/// Placeholders protected per line; a line with more keeps the rest as plain text.+const MAX_PLACEHOLDERS: usize = 64;++fn push_rtl_line(out: &mut String, line: &str) {+    let edge = |c: char| c.is_whitespace() || c == '·';+    let core = line.trim_start_matches(edge);+    let lead = line.get(..line.len().saturating_sub(core.len())).unwrap_or_default();+    let trimmed = core.trim_end_matches(edge);+    let tail = core.get(trimmed.len()..).unwrap_or_default();+    let mut core = trimmed;+    let mut colon = "";+    if let Some(caption) = core.strip_suffix(':')+        && caption.split_whitespace().count() <= CAPTION_WORDS+    {+        core = caption;+        colon = ":";+    }+    // A line that already uses the stand-in characters is reordered with its placeholders as text.+    let reserved = core.chars().any(|c| (PLACEHOLDER_BASE..PLACEHOLDER_BASE.saturating_add(MAX_PLACEHOLDERS as u32)).contains(&u32::from(c)));+    let (masked, names) = if reserved { (core.to_owned(), Vec::new()) } else { mask_placeholders(core) };+    let mut visual = String::with_capacity(masked.len());+    push_visual(&mut visual, &masked, Level::rtl());+    out.push_str(lead);+    for c in visual.chars() {+        let index = u32::from(c).checked_sub(PLACEHOLDER_BASE).and_then(|i| usize::try_from(i).ok());+        match index.and_then(|i| names.get(i)) {+            Some(name) => out.push_str(name),+            None => out.push(c),+        }+    }+    out.push_str(colon);+    out.push_str(tail);+}++/// `text` with each `{name}` placeholder and each keyboard shortcut (`⇧⌘+`) replaced by one+/// stand-in character, and what they replaced, in order. A shortcut's symbols have no direction+/// of their own, so left alone they would be drawn in reverse.+fn mask_placeholders(text: &str) -> (String, Vec<&str>) {+    let mut masked = String::with_capacity(text.len());+    let mut names: Vec<&str> = Vec::new();+    let mut rest = text;+    while names.len() < MAX_PLACEHOLDERS+        && let Some((start, end)) = next_atom(rest)+    {+        let (Some(before), Some(atom), Some(after)) = (rest.get(..start), rest.get(start..end), rest.get(end..)) else { break };+        // `names.len()` is below MAX_PLACEHOLDERS, so the sum is a private-use scalar.+        let Some(stand_in) = u32::try_from(names.len()).ok().and_then(|i| char::from_u32(PLACEHOLDER_BASE.saturating_add(i))) else { break };+        masked.push_str(before);+        masked.push(stand_in);+        names.push(atom);+        rest = after;+    }+    masked.push_str(rest);+    (masked, names)+}++/// The byte range of the first placeholder or keyboard shortcut in `text`.+fn next_atom(text: &str) -> Option<(usize, usize)> {+    let placeholder = text.find('{').and_then(|open| {+        let len = text.get(open..)?.find('}')?;+        Some((open, open.saturating_add(len).saturating_add(1)))+    });+    let shortcut = text.find(is_modifier_key).map(|at| {+        // The shortcut is the stretch around the symbol up to a space, a bracket or script text.+        let outside = |c: char| c.is_whitespace() || is_rtl_script(c) || matches!(c, '(' | ')' | '[' | ']' | '{' | '}');+        let start = text+            .get(..at)+            .and_then(|before| before.rfind(outside).and_then(|i| before.get(i..)?.chars().next().map(|c| i.saturating_add(c.len_utf8()))))+            .unwrap_or(0);+        let end = text.get(at..).and_then(|after| after.find(outside)).map_or(text.len(), |i| at.saturating_add(i));+        (start, end)+    });+    match (placeholder, shortcut) {+        (Some(p), Some(s)) => Some(if s.0 < p.0 { s } else { p }),+        (p, s) => p.or(s),+    }+}++/// The modifier-key symbols of keyboard shortcuts: ⌘ ⇧ ⌥ ⌃.+fn is_modifier_key(c: char) -> bool {+    matches!(c, '\u{2318}' | '\u{21E7}' | '\u{2325}' | '\u{2303}')+}++/// Characters of the right-to-left scripts' blocks: letters, marks and the scripts' own+/// punctuation, all drawn by the script's face.+fn is_rtl_script(c: char) -> bool {+    matches!(u32::from(c), 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFC | 0x1_0800..=0x1_0FFF | 0x1_E800..=0x1_EFFF)+}++/// The zero-width (non-)joiners inside a word (Persian `می‌روم`): egui shapes them with the+/// letter before them.+fn is_joiner(c: char) -> bool {+    matches!(c, '\u{200C}' | '\u{200D}')+}++/// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn.+fn is_bidi_control(c: char) -> bool {+    matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')+}++fn mirrored(c: char) -> char {+    match c {+        '(' => ')',+        ')' => '(',+        '[' => ']',+        ']' => '[',+        '{' => '}',+        '}' => '{',+        '<' => '>',+        '>' => '<',+        '«' => '»',+        '»' => '«',+        other => other,+    }+}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 => "SF Arabic, Geeza Pro, Segoe UI, Tahoma, Arial, Noto Sans Arabic and DejaVu Sans",     } } @@ -123,9 +125,55 @@ 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. The faces are small next to a CJK one,+/// and every desktop ships at least one.+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 = "macos")]+    {+        // SF Arabic may be a downloaded font asset on later macOS versions.+        add("/System/Library/Fonts/SFArabic.ttf".into(), "SF Arabic");+        for path in mac_font_assets("SFArabic.ttf") {+            add(path, "SF Arabic");+        }+        add("/System/Library/Fonts/GeezaPro.ttc".into(), "Geeza Pro");+        add("/System/Library/Fonts/Supplemental/Arial.ttf".into(), "Arial");+    }+    #[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("tahoma.ttf"), "Tahoma");+        add(root.join("Fonts").join("arial.ttf"), "Arial");+    }+    #[cfg(all(unix, not(target_os = "macos")))]+    {+        // Debian/Ubuntu, Arch, Fedora and FreeBSD's ports: Noto Sans Arabic (static and+        // variable), then DejaVu Sans.+        const AR_DIRS: [&str; 8] = [+            "/usr/share/fonts/truetype/noto",+            "/usr/share/fonts/opentype/noto",+            "/usr/share/fonts/noto",+            "/usr/share/fonts/google-noto",+            "/usr/share/fonts/noto-sans-arabic",+            "/usr/share/fonts/truetype/dejavu",+            "/usr/share/fonts/dejavu",+            "/usr/local/share/fonts/dejavu",+        ];+        for file in ["NotoSansArabic-Regular.ttf", "NotoSansArabic-VF.ttf", "DejaVuSans.ttf"] {+            for dir in AR_DIRS {+                add(Path::new(dir).join(file), if file.starts_with("Noto") { "Noto Sans Arabic" } else { "DejaVu Sans" });+            }+        }+    }+    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,348 @@+# 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 (the six CLDR 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 the terms Arabic-speaking users of creative software know.+# The user is addressed with the singular second person.+#+# Write every entry in logical (typing) order; i18n puts right-to-left text into display+# order when the catalog loads (bidi::display_rtl).+#+# 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), "GitHub", "Discord", "Flatpak",+# package formats and the download sites.+# Digits are Western (0-9). Trailing "…" (U+2026) must be preserved. Placeholders {name},+# {n}, {version}, {count}, {v}, {what}, {unit}, {real}, {e} must appear in translation.++	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.	يتوفر الإصدار {version} من ArtCraft Launcher.+	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+	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…	جارٍ انتظار عمليات تثبيت أخرى…++# Package formats offered for download (PackageKind::label)+	Installer (.msi)	مثبِّت (.msi)+	Portable (.zip)	نسخة محمولة (.zip)+	Disk image (.dmg)	صورة قرص (.dmg)+	Debian package (.deb)	حزمة Debian ‏(.deb)+	RPM package (.rpm)	حزمة RPM ‏(.rpm)+	Flatpak bundle	حزمة Flatpak+	Tarball (.tar.gz)	أرشيف Tar ‏(.tar.gz)++# Contextual: each app's tagline, keyed by app id+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	تسجيل الصوت وتحريره ومزجه++# Contextual: month abbreviations for date rendering+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…	جارٍ التحقق من المجموع الاختباري…+	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}+	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	يتوفر الإصدار {v} من {name}+	{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}++# Contextual: table column headers+column	App	التطبيق+column	Installed	المثبَّت+column	Latest	الأحدث+column	Released	تاريخ الإصدار++# Contextual: the filter tab names a group (the status pill says "Installed")+filter	Installed	المثبَّتة+	{name} details	تفاصيل {name}++# Plural forms for Arabic (zero|one|two|few|many|other)+@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} يوم++# Message templates (@msg): toasts from the backend. {placeholders} match any text.+@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} قيد التشغيل. أغلقه ثم أعد المحاولة.++# Command IDs for menu items (@id)+# Format: @id<TAB>command_id<TAB>translation+@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	المظهر++# Tray (menu bar) menu and tooltip+	Show ArtCraft Launcher	إظهار ArtCraft Launcher+	Check for Updates	التحقق من التحديثات+	Visit getartcraft.com	زيارة getartcraft.com+	ArtCraft on GitHub	ArtCraft على GitHub+	Join the ArtCraft Discord	الانضمام إلى Discord الخاص بـArtCraft+	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.	تعذّر تحميل الصور. أعد فتح هذه الصفحة للمحاولة مجددًا.+@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} تحديث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@@ -5,8 +5,8 @@ //! - Empty context = plain UI string (`tl!`, `t`). //! - `@id` context = keyed by command id (`tr_id`). //! - `@plural` context = plural forms: the English `one|other` key, and the language's forms-//!   separated by `|` (one form for a language whose nouns don't inflect for number, up to-//!   three: `one|few|many` for Russian, Ukrainian and Polish).+//!   separated by `|` (one form for a language whose nouns don't inflect for number, up to six:+//!   `zero|one|two|few|many|other` for Arabic). //! - `@msg` context = a status/error message, or a template whose `{placeholders}` match any text //!   (`message`, `msg`). //! - Any other context = disambiguation for `tr_ctx`.@@ -120,8 +120,8 @@ impl Catalog {                 "@id" => cat.by_id.insert(source_text.clone(), translation).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()@@ -146,6 +146,18 @@ impl Catalog {         cat     } +    /// Rewrite every translation with `f` (a right-to-left catalog's entries go to display+    /// order). Sources and keys stay as they are.+    pub fn map_translations(&mut self, f: impl Fn(&str) -> String) {+        let tables = std::iter::once(&mut self.plain).chain(self.contextual.values_mut()).chain(std::iter::once(&mut self.by_id));+        for translation in tables.flat_map(HashMap::values_mut).chain(self.plural.values_mut().flatten()).chain(self.messages.values_mut()) {+            *translation = f(translation);+        }+        for template in &mut self.templates {+            template.translation = f(&template.translation);+        }+    }+     /// Look up a plain string.     pub fn plain(&self, s: &str) -> Option<&str> {         self.plain.get(s).map(String::as_str)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.@@ -42,6 +43,9 @@ pub struct LangInfo {     pub plural_forms: usize,     /// Must the catalog cover every `tl!` literal? (checked by tests)     pub complete_menus: bool,+    /// Read right to left (Arabic): the catalog's translations are put into display order when+    /// it loads ([`crate::bidi::display_rtl`]).+    pub rtl: bool,     catalog: OnceLock<Catalog>, } @@ -81,6 +85,19 @@ pub(super) fn plural_czech(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,+    }+}+ /// Polish integer counts: only 1 itself is `one` (21 is "21 aplikacji"), 2–4 in the last digit /// (but not 12–14) `few`, everything else `many`. pub(super) fn plural_polish(n: u64) -> usize {@@ -94,7 +111,7 @@ pub(super) fn plural_polish(n: u64) -> usize { }  /// 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",@@ -104,6 +121,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_one_other,         plural_forms: 2,         complete_menus: false,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -115,6 +133,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_east_slavic,         plural_forms: 3,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -126,6 +145,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_none,         plural_forms: 1,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -137,6 +157,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_one_other,         plural_forms: 2,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -148,6 +169,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_zero_one,         plural_forms: 2,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -159,6 +181,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_one_other,         plural_forms: 2,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -170,6 +193,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_one_other,         plural_forms: 2,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -181,6 +205,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_czech,         plural_forms: 3,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -192,6 +217,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_polish,         plural_forms: 3,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -203,6 +229,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_one_other,         plural_forms: 2,         complete_menus: true,+        rtl: false,         catalog: OnceLock::new(),     },     LangInfo {@@ -214,13 +241,34 @@ pub static LANGUAGES: [LangInfo; 11] = [         plural: plural_none,         plural_forms: 1,         complete_menus: true,+        rtl: false,+        catalog: OnceLock::new(),+    },+    // Arabic (Modern Standard, Western digits); every `ar-*` locale (`ar-MA`, `ar-EG`, `ar-SA`…)+    // resolves here. Drawn with the system's Arabic font and right to left (see `LangInfo::rtl`).+    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,+        rtl: true,         catalog: OnceLock::new(),     }, ];  impl LangInfo {     pub(super) fn catalog(&self) -> &Catalog {-        self.catalog.get_or_init(|| Catalog::parse(self.source))+        self.catalog.get_or_init(|| {+            let mut catalog = Catalog::parse(self.source);+            if self.rtl {+                catalog.map_translations(crate::bidi::display_rtl);+            }+            catalog+        })     } } @@ -254,6 +302,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@@ -284,6 +333,12 @@ impl Lang {         self.0.english_name     } +    /// Read right to left: the catalog's translations come back in display order (see+    /// [`crate::bidi::display_rtl`]).+    pub fn rtl(self) -> bool {+        self.0.rtl+    }+     /// Does drawing this language need a CJK font from the system?     pub fn needs_cjk_font(self) -> bool {         self.0.script.is_some()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@@ -8,8 +8,10 @@ //!    catalog, cover every UI literal and plural key; the tests enforce that claim. A translation //!    that isn't finished yet starts with `complete_menus: false`: it still shows in //!    Preferences, strings without a row fall back to English, and the completeness tests-//!    skip it until every row is done. A CJK language also sets `script` and takes its system-//!    font from `crate::cjk_font`.+//!    skip it until every row is done. A language written in a script the bundled faces can't+//!    draw also sets `script` and takes its system font from `crate::cjk_font`. A right-to-left+//!    language (Arabic) sets `rtl`: its catalog is written in typing order and reordered for+//!    display when it loads (`crate::bidi::display_rtl`). //! //! That is all: the Preferences dropdown, the system-locale match and the catalog tests //! (parse, placeholders, plural forms, coverage of every `tl!` literal) pick it up from thediff --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@@ -384,6 +384,69 @@ fn japanese_locales_and_counts_work() {     assert_eq!(message(Lang::JA, "PhotoCraft updated to 0.5.0"), "PhotoCraft を 0.5.0 に更新しました"); } +#[test]+fn arabic_locales_and_plurals_work() {+    for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant"] {+        assert_eq!(lang_from_tag(tag), Some(Lang::AR), "{tag}");+    }+    assert_eq!(super::detect::first_supported(["xx-XX", "ar-EG", "en-US"]), Some(Lang::AR));+    // Arabic is the only right-to-left language.+    assert!(Lang::all().filter(|l| l.rtl()).eq([Lang::AR]));+    // The six CLDR forms: zero (0), one (1), two (2), few (3–10), many (11–99), other.+    let form = |n: u64| plural_arabic(n);+    assert_eq!([0, 1, 2].map(form), [0, 1, 2]);+    assert_eq!([3, 10, 103, 110, 1003].map(form), [3; 5]);+    assert_eq!([11, 99, 111, 199, 1011].map(form), [4; 5]);+    assert_eq!([100, 101, 102, 200, 1000].map(form), [5; 5]);+    // Lookups come back in display order; the count fills its placeholder in place.+    assert_eq!(trn(Lang::AR, 0, "{n} app", "{n} apps"), "تطبيقات 0");+    assert_eq!(trn(Lang::AR, 1, "{n} app", "{n} apps"), "تطبيق 1");+    assert_eq!(trn(Lang::AR, 2, "{n} app", "{n} apps"), "تطبيقان 2");+    assert_eq!(trn(Lang::AR, 5, "{n} app", "{n} apps"), "تطبيقات 5");+    assert_eq!(trn(Lang::AR, 11, "{n} app", "{n} apps"), "تطبيقًا 11");+    assert_eq!(trn(Lang::AR, 100, "{n} app", "{n} apps"), "تطبيق 100");+    assert_eq!(trn(Lang::AR, 3, "{n} unknown item", "{n} unknown items"), "3 unknown items");+    assert_eq!(tr(Lang::AR, "Update to {v}"), "{v} إلى التحديث");+    assert_eq!(super::fmt(tr(Lang::AR, "Update to {v}"), &[("v", "0.5.0")]), "0.5.0 إلى التحديث");+    assert_eq!(tr(Lang::AR, "Settings"), "الإعدادات");+    assert_eq!(message(Lang::AR, "PhotoCraft updated to 0.5.0"), "0.5.0 إلى حُدِّث PhotoCraft");+    assert_eq!(tr(Lang::AR, "Untranslated diagnostic"), "Untranslated diagnostic");+}++/// With a system Arabic font installed, every UI family can draw the whole Arabic catalog.+/// Machines without one (CI, the web) skip this; the UI then stays in English.+#[test]+fn system_font_covers_arabic_in_every_ui_family() {+    use egui::{FontFamily, FontId};+    if !crate::theme::cjk_font_available(Lang::AR) {+        return;+    }+    let ctx = egui::Context::default();+    crate::theme::install_fonts(&ctx, Some(Lang::AR));+    let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});+    output.textures_delta.clear();+    let text: String = Lang::AR+        .0+        .source+        .lines()+        .filter_map(|line| line.split('\t').nth(2))+        .flat_map(str::chars)+        .filter(|c| ('\u{0600}'..='\u{06ff}').contains(c) || ('\u{fb50}'..='\u{fdff}').contains(c))+        .collect();+    assert!(text.chars().count() > 500);+    for family in [+        FontFamily::Proportional,+        FontFamily::Monospace,+        crate::theme::medium(),+        crate::theme::semibold(),+        crate::theme::display(),+        crate::theme::mono(),+        crate::theme::mono_bold(),+    ] {+        ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing glyph in {family:?}"));+    }+}+ /// With the system's Chinese font installed, every UI family can draw the whole Chinese catalog. /// Machines without one (CI, the web) skip this; the UI then stays in English. #[test]@@ -464,6 +527,8 @@ fn language_labels_come_from_the_registry() {     assert_eq!(Settings::language_label(Lang::ZH_CN.code(), Some(Lang::ZH_CN)), "Simplified Chinese (简体中文)");     assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::ZH_CN)), "Japanese");     assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::JA)), "Japanese (日本語)");+    assert_eq!(Settings::language_label(Lang::AR.code(), Some(Lang::ZH_CN)), "Arabic");+    assert_eq!(Settings::language_label(Lang::AR.code(), Some(Lang::AR)), "Arabic (العربية)");     assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO, None), "Auto");     assert_eq!(Settings::language_label("xx", None), "Auto"); }diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -17,6 +17,7 @@ macro_rules! tl { }  mod app;+mod bidi; #[cfg(not(target_arch = "wasm32"))] mod cjk_font; mod details;