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
@@ -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"@@ -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`.new file mode 100644@@ -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,+ }+}@@ -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();new file mode 100644@@ -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} تحديث@@ -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)@@ -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()@@ -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 the@@ -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"); }@@ -17,6 +17,7 @@ macro_rules! tl { } mod app;+mod bidi; #[cfg(not(target_arch = "wasm32"))] mod cjk_font; mod details;