Browse / github.com/storytold/artcraft / patch
Add Arabic (العربية) interface translation
Add Arabic (العربية) interface translation A translation layer for `artcraft-native`: a 278-entry dictionary in `i18n.rs` (binary-searched, tested sorted) covering the whole shell — menus, buttons, pickers, toasts, the prompt box, option panels and the toolbar — written from the plain meaning of the English with the AI-art vocabulary in Modern Standard Arabic (المطالبة النصية، مراجع، مقطع صوتي، نموذج، لوحة). English plural suffixes are blanked where Arabic needs none, and the format strings carry their values through `tf()` in order. - **Switch** — the language picker in Settings ▸ General, persisted in the saved state (old saves stay
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- hasan-sdaif (imported from pull request #2229, open)
- Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373- Size
- +930 −111 in 29 files
- Review
- pending
- Shared
- 2026-10-28
- Id
bbf96d06e7a004cf781f6fb3
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock artcraft-native/Cargo.toml artcraft-native/src/app.rs artcraft-native/src/feed/grid.rs artcraft-native/src/feed/types.rs artcraft-native/src/i18n.rs artcraft-native/src/i18n/bidi.rs artcraft-native/src/main.rs artcraft-native/src/overlays/account.rs artcraft-native/src/overlays/characters.rs artcraft-native/src/overlays/library_picker.rs artcraft-native/src/overlays/lightbox.rs artcraft-native/src/overlays/providers.rs artcraft-native/src/pages/common.rs artcraft-native/src/pages/create_audio.rs artcraft-native/src/pages/create_image.rs artcraft-native/src/pages/create_video.rs artcraft-native/src/pages/other.rs artcraft-native/src/pages/toolbar.rs artcraft-native/src/prompt_box/deck.rs artcraft-native/src/prompt_box/editor.rs artcraft-native/src/prompt_box/mod.rs artcraft-native/src/prompt_box/model_selector.rs artcraft-native/src/prompt_box/pickers.rs artcraft-native/src/shell.rs artcraft-native/src/theme.rs artcraft-native/src/ui/toast.rs artcraft-native/src/ui/waveform.rs artcraft-native/src/ui/widgets.rs
@@ -512,6 +512,7 @@ dependencies = [ "tempfile", "tokens", "tokio",+ "unicode-bidi", "url", "uuid 1.22.0", "wry",@@ -9741,9 +9742,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-bidi"-version = "0.3.15"+version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi-mirroring"@@ -26,6 +26,9 @@ chrono.workspace = true directories = "6.0.0" eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } egui = "0.36"+# The Arabic dictionary's translations are handed out in visual order (i18n/bidi.rs): a UAX+# #9 reordering pass for egui's run-by-run, left-to-right text layout.+unicode-bidi = "0.3.18" enums.workspace = true egui_extras = { version = "0.36", default-features = false, features = ["image", "svg"] } env_logger.workspace = true@@ -55,6 +55,7 @@ const MODALITIES: [Modality; 3] = [Modality::Image, Modality::Video, Modality::A #[serde(default)] struct Saved { page: Page,+ language: crate::i18n::Language, enter_to_generate: bool, view_mode: ViewMode, autoplay: bool,@@ -66,11 +67,12 @@ struct Saved { impl Default for Saved { fn default() -> Self {- Self { page: Page::CreateImage, enter_to_generate: false, view_mode: ViewMode::Grid, autoplay: true, sidebar_open: true, image: ImageSettings { count: 1, ..Default::default() }, video: VideoSettings::default(), audio: AudioSettings::default() }+ Self { page: Page::CreateImage, language: crate::i18n::Language::English, enter_to_generate: false, view_mode: ViewMode::Grid, autoplay: true, sidebar_open: true, image: ImageSettings { count: 1, ..Default::default() }, video: VideoSettings::default(), audio: AudioSettings::default() } } } pub struct ArtcraftApp {+ language: crate::i18n::Language, backend: Backend, cache: MediaCache, player: AudioPlayer,@@ -122,6 +124,7 @@ impl ArtcraftApp { theme::apply(&cc.egui_ctx); egui_extras::install_image_loaders(&cc.egui_ctx); let mut saved: Saved = cc.storage.and_then(|s| eframe::get_value(s, STORAGE_KEY)).unwrap_or_default();+ crate::i18n::set_language(saved.language); if let Some(page) = std::env::var("ARTCRAFT_PAGE").ok().and_then(|p| Page::from_slug(&p)) { saved.page = page; }@@ -132,7 +135,7 @@ impl ArtcraftApp { backend.load_models(modality); } backend.refresh_session();- let mut app = Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, provider_setup: None, midjourney_login: None, fal_key_input: String::new(), preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend };+ let mut app = Self { language: saved.language, cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, provider_setup: None, midjourney_login: None, fal_key_input: String::new(), preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }; app.restore_provider_jobs(); // Diagnostics: `ARTCRAFT_OPEN=settings` or `midjourney-login` opens that dialog at launch. match std::env::var("ARTCRAFT_OPEN").as_deref() {@@ -194,7 +197,7 @@ impl ArtcraftApp { }, Event::Models(_, Err(message)) => { if self.models_retry_at.is_none() {- self.toasts.error(format!("Couldn't load models: {message}"));+ self.toasts.error(crate::i18n::tf("Couldn't load models: {}", &[message.to_string()])); } self.models_retry_at = Some(ctx.input(|i| i.time) + MODEL_RETRY_INTERVAL); },@@ -224,7 +227,7 @@ impl ArtcraftApp { if let Some(modal) = &mut self.characters_modal { match modal.apply_upload(ref_id, result.clone()) { Some(Err(err)) => {- self.toasts.error(format!("Failed to upload the reference image: {err}"));+ self.toasts.error(crate::i18n::tf("Failed to upload the reference image: {}", &[err.to_string()])); return; }, Some(Ok(())) => return,@@ -249,7 +252,7 @@ impl ArtcraftApp { self.pending_characters.retain(|(job, name)| match jobs.iter().find(|j| &j.job_token == job).map(|j| j.status.phase()) { Some(JobPhase::Succeeded) => false, Some(JobPhase::Failed) => {- toasts.error(format!("Character \u{201c}{name}\u{201d} failed to create"));+ toasts.error(crate::i18n::tf("Character \u{201c}{}\u{201d} failed to create", &[name.to_string()])); false }, _ => true,@@ -261,7 +264,7 @@ impl ArtcraftApp { for notice in notices { match notice { FeedNotice::Completed => {- self.toasts.success(format!("{} generation complete!", kind.label()));+ self.toasts.success(crate::i18n::tf("{} generation complete!", &[crate::i18n::t(kind.label())])); self.backend.refresh_credits(); }, FeedNotice::Failed(reason) => self.toasts.error(reason),@@ -292,7 +295,7 @@ impl ArtcraftApp { } }, }- self.toasts.error(format!("Couldn't load your library: {message}"));+ self.toasts.error(crate::i18n::tf("Couldn't load your library: {}", &[message.to_string()])); }, Event::Batch { job_token, items } => { let kind = self.batch_kinds.remove(&job_token).unwrap_or(MediaKind::Image);@@ -311,7 +314,7 @@ impl ArtcraftApp { }, Event::Characters(list) => self.characters = list, Event::CharacterCreating { job_token, name } => {- self.toasts.success(format!("Character \u{201c}{name}\u{201d} is being created"));+ self.toasts.success(crate::i18n::tf("Character \u{201c}{}\u{201d} is being created", &[name.to_string()])); self.pending_characters.push((job_token, name)); self.next_poll = 0.0; },@@ -341,7 +344,7 @@ impl ArtcraftApp { self.lightbox = None; } },- Event::Saved(path) => self.toasts.success(format!("Downloaded {}", common::file_name(&path))),+ Event::Saved(path) => self.toasts.success(crate::i18n::tf("Downloaded {}", &[common::file_name(&path).to_string()])), Event::Toast { error, message } => { if error { self.toasts.error(message);@@ -418,7 +421,7 @@ impl ArtcraftApp { }, AppRequest::PlayExternally(url) => { if let Err(err) = open::that(&url) {- self.toasts.error(format!("Couldn't open the file: {err}"));+ self.toasts.error(crate::i18n::tf("Couldn't open the file: {}", &[err.to_string()])); } }, AppRequest::LoadMoreLibrary(key) => {@@ -748,7 +751,7 @@ impl ArtcraftApp { let username = self.user.as_ref().map(|u| u.username.clone()); let accounts = self.backend.accounts(); let view = AccountsView { midjourney: accounts.midjourney_account(), fal_key_hint: accounts.fal_key_hint() };- match settings_dialog(ctx, &mut self.enter_to_generate, username.as_deref(), self.credits, &view, &mut self.fal_key_input, self.backend.data_root()) {+ match settings_dialog(ctx, &mut self.enter_to_generate, username.as_deref(), self.credits, &view, &mut self.fal_key_input, self.backend.data_root(), &mut self.language) { Some(SettingsAction::Close) => self.settings_open = false, Some(SettingsAction::SignOut) => { self.settings_open = false;@@ -816,11 +819,11 @@ impl ArtcraftApp { self.fal_key_input.clear(); self.toasts.success("FAL API key saved"); },- Err(err) => self.toasts.error(format!("Failed to save API key: {err}")),+ Err(err) => self.toasts.error(crate::i18n::tf("Failed to save API key: {}", &[err.to_string()])), }, AccountAction::RemoveFalKey => { if let Err(err) = self.backend.accounts().clear_fal_key() {- self.toasts.error(format!("Failed to clear provider credential: {err}"));+ self.toasts.error(crate::i18n::tf("Failed to clear provider credential: {}", &[err.to_string()])); } }, }@@ -853,7 +856,7 @@ impl ArtcraftApp { LightboxAction::CopyPrompt(text) => self.handle_request(ctx, AppRequest::CopyText(text)), LightboxAction::Play(url) => { if let Err(err) = open::that(&url) {- self.toasts.error(format!("Couldn't open the video: {err}"));+ self.toasts.error(crate::i18n::tf("Couldn't open the video: {}", &[err.to_string()])); } }, }@@ -890,7 +893,7 @@ impl eframe::App for ArtcraftApp { } fn save(&mut self, storage: &mut dyn eframe::Storage) {- let saved = Saved { page: self.page, enter_to_generate: self.enter_to_generate, view_mode: self.view_mode, autoplay: self.autoplay, sidebar_open: self.sidebar_open, image: self.image.settings.clone(), video: self.video.settings.clone(), audio: self.audio.settings.clone() };+ let saved = Saved { page: self.page, language: crate::i18n::language(), enter_to_generate: self.enter_to_generate, view_mode: self.view_mode, autoplay: self.autoplay, sidebar_open: self.sidebar_open, image: self.image.settings.clone(), video: self.video.settings.clone(), audio: self.audio.settings.clone() }; eframe::set_value(storage, STORAGE_KEY, &saved); } @@ -183,7 +183,7 @@ fn pending_card(ui: &mut Ui, rect: Rect, job: &PendingJob, now: i64, names: &dyn if job.batch_count > 1 { let banner = Rect::from_min_max(rect.min + vec2(8.0, 8.0), pos2(rect.right() - 8.0, rect.top() + 34.0)); p.rect_filled(banner, 0.0, Color32::from_black_alpha(153));- p.text(banner.center(), Align2::CENTER_CENTER, format!("Generating {} {} \u{b7} Results may appear one at a time", job.batch_count, job.kind.plural()), FontId::new(10.0, FontFamily::Proportional), theme::fade(Color32::WHITE, 0.7));+ p.text(banner.center(), Align2::CENTER_CENTER, crate::i18n::tf("Generating {} {} \u{b7} Results may appear one at a time", &[job.batch_count.to_string(), crate::i18n::t(job.kind.plural())]), FontId::new(10.0, FontFamily::Proportional), theme::fade(Color32::WHITE, 0.7)); } let c = rect.center() - vec2(0.0, 18.0); icons::paint_spinner(ui, Rect::from_center_size(c, vec2(26.0, 26.0)), theme::fade(Color32::WHITE, 0.2));@@ -367,7 +367,7 @@ fn item_actions<'a>(view: &FeedView<'_>, item: &FeedItem, prompt: Option<&String /// What an item is called without its prompt: its title, else "Audio Generation" and the like. fn fallback_title(item: &FeedItem) -> String {- item.title.clone().unwrap_or_else(|| format!("{} Generation", item.kind.label()))+ item.title.clone().unwrap_or_else(|| crate::i18n::tf("{} Generation", &[crate::i18n::t(item.kind.label())])) } fn kind_icon(kind: MediaKind) -> Icon {@@ -393,11 +393,11 @@ fn pending_row(ui: &mut Ui, rect: Rect, job: &PendingJob, now: i64, names: &dyn let meta_y = thumb.top() + gh + 14.0; let mx = model_chip(ui, pos2(x, meta_y), job.model_id.as_deref().unwrap_or_default(), names, 12.0, theme::MUTED); if job.batch_count > 1 {- ui.painter().text(pos2(mx + 8.0, meta_y), Align2::LEFT_CENTER, format!("\u{b7} {} {}", job.batch_count, job.kind.plural()), FontId::new(12.0, FontFamily::Proportional), theme::MUTED);+ ui.painter().text(pos2(mx + 8.0, meta_y), Align2::LEFT_CENTER, crate::i18n::tf("\u{b7} {} {}", &[job.batch_count.to_string(), crate::i18n::t(job.kind.plural())]), FontId::new(12.0, FontFamily::Proportional), theme::MUTED); } let bar = Rect::from_min_size(pos2(x, meta_y + 16.0), vec2(w.min(320.0), 4.0)); widgets::progress_bar(ui.painter(), bar, progress as f32 / 100.0);- ui.painter().text(pos2(bar.right() + 10.0, bar.center().y), Align2::LEFT_CENTER, format!("{progress}% \u{b7} {label}"), FontId::new(11.0, FontFamily::Monospace), theme::FAINT);+ ui.painter().text(pos2(bar.right() + 10.0, bar.center().y), Align2::LEFT_CENTER, crate::i18n::tf("{}% \u{b7} {}", &[progress.to_string(), label.to_string()]), FontId::new(11.0, FontFamily::Monospace), theme::FAINT); row_divider(ui, rect); } @@ -92,13 +92,13 @@ pub fn pending_status(job: &PendingJob, now: i64) -> (u8, String) { let left = (job.expected_secs - elapsed).max(1.0) as i64; let (h, m, s) = (left / 3600, (left % 3600) / 60, left % 60); let label = if h > 0 && m > 0 {- format!("~{h}h {m}m")+ crate::i18n::tf("~{}h {}m", &[h.to_string(), m.to_string()]) } else if h > 0 {- format!("~{h}h")+ crate::i18n::tf("~{}h", &[h.to_string()]) } else if m > 0 {- format!("~{m}m")+ crate::i18n::tf("~{}m", &[m.to_string()]) } else {- format!("~{s}s")+ crate::i18n::tf("~{}s", &[s.to_string()]) }; (progress, label) }@@ -106,7 +106,7 @@ pub fn pending_status(job: &PendingJob, now: i64) -> (u8, String) { /// "just now", "5 minutes ago", … for list rows. pub fn time_ago(then: i64, now: i64) -> String { let d = (now - then).max(0);- let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" });+ let plural = |n: i64, unit: &str| crate::i18n::tf("{} {}{} ago", &[n.to_string(), crate::i18n::t(unit), (if n == 1 || crate::i18n::is_arabic() { "" } else { "s" }).to_string()]); match d { 0..=59 => "just now".to_owned(), 60..=3599 => plural(d / 60, "minute"),new file mode 100644@@ -0,0 +1,529 @@+//! Internationalization (i18n): English is the source language; العربية (Arabic) is a+//! runtime translation selected from the UI. The dictionary maps every English UI string the+//! app shows to its Arabic counterpart; unknown strings fall back to English, so anything+//! not yet translated keeps working.+//!+//! Usage: `crate::i18n::t("Sign in")` at display time, and+//! `tf("Signed in as @{}", &[user])` for patterns with `{}` placeholders.+//! `set_language`/`language` drive the language state (persisted in `Saved`), and//!+//! egui lays a label's runs left to right and runs no bidi over them, so Arabic's+//! translations are handed out in visual order (`bidi.rs`): the runs in the order a+//! right-to-left reader meets them, the letters inside each run in logical order for the+//! shaper, brackets mirrored. What a user types keeps its logical order.++mod bidi;++use std::sync::OnceLock;+use std::sync::atomic::{AtomicU8, Ordering};++/// The UI language. English is the source language; العربية (Arabic) is right-to-left.+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]+#[serde(rename_all = "camelCase")]+pub enum Language {+ #[default]+ English,+ Arabic,+}++impl Language {+ /// BCP 47 code, e.g. `"en"`, `"ar"`.+ pub fn code(self) -> &'static str {+ match self {+ Self::English => "en",+ Self::Arabic => "ar",+ }+ }++ /// The language's own name, shown in the language picker.+ pub fn name(self) -> &'static str {+ match self {+ Self::English => "English",+ Self::Arabic => "العربية",+ }+ }++ /// Parse a BCP 47 code (case-insensitive; unknown codes are English). Any `ar-XX`+ /// region (ar-SA, ar-EG …) resolves to Arabic, like the other Craft apps.+ pub fn from_code(code: &str) -> Self {+ let code = code.replace('_', "-");+ let code = code.as_str();+ if code.eq_ignore_ascii_case("ar") || code.to_ascii_lowercase().starts_with("ar-") { Self::Arabic } else { Self::English }+ }++ /// Every language, in display order.+ pub const ALL: &'static [Language] = &[Language::English, Language::Arabic];+}++/// The active language, mirrored into a global so every `t()` call is cheap.+static LANGUAGE: AtomicU8 = AtomicU8::new(Language::English as u8);++/// Set the active language (from the UI when it changes, and on load).+pub fn set_language(l: Language) {+ LANGUAGE.store(l as u8, Ordering::Relaxed);+}++/// The active language.+pub fn language() -> Language {+ match LANGUAGE.load(Ordering::Relaxed) {+ 1 => Language::Arabic,+ _ => Language::English,+ }+}++/// Is the UI in Arabic (right-to-left)?+pub fn is_arabic() -> bool {+ language() == Language::Arabic+}++/// Translate an English UI string into the active language. Unknown strings (and English)+/// come back unchanged, so untranslated additions keep working.+pub fn t(s: &str) -> String {+ if !is_arabic() {+ return s.to_string();+ }+ match tr_ar_index(s) {+ // Arabic's translations come back in visual order (bidi.rs): the runs in the order+ // egui draws them, left to right, so the label reads right to left.+ Some(i) => visual_ar()[i].clone(),+ None => s.to_string(),+ }+}++/// Translate a pattern with positional placeholders, substituting the arguments in order.+/// A placeholder is `{}` or a bare format spec such as `{:.1}` (the argument is expected+/// to be pre-formatted); the translated pattern keeps its placeholders, so word order is+/// free.+pub fn tf(pattern: &str, args: &[String]) -> String {+ if !is_arabic() {+ return fill(pattern, args);+ }+ match tr_ar_index(pattern) {+ // The pattern is filled in logical order and reordered afterwards (bidi.rs), so two+ // placeholders cannot land in each other's places and a number keeps its reading spot.+ Some(i) => bidi::visual_rtl(&fill(AR[i].1, args)),+ None => fill(pattern, args),+ }+}++/// `pattern` with each positional placeholder (`{}`, or a bare format spec such as `{:.1}`)+/// replaced by the next of `args`, in order. A placeholder is never read again, so an+/// argument containing `{` stays intact.+fn fill(pattern: &str, args: &[String]) -> String {+ let mut out = pattern.to_string();+ for a in args {+ // The next positional placeholder: `{}`, or `{` `:` … `}` (a format spec, no nesting).+ let mut span = None;+ let b = out.as_bytes();+ let mut i = 0;+ while i < b.len() {+ if b[i] == b'{' {+ if let Some(e) = out[i + 1..].find('}') {+ let inner = &out[i + 1..i + 1 + e];+ if inner.is_empty() || inner.starts_with(':') {+ span = Some(i..i + 2 + e);+ break;+ }+ }+ }+ i += 1;+ }+ if let Some(p) = span {+ out.replace_range(p, a);+ }+ }+ out+}++/// The Arabic dictionary: English → العربية, sorted for binary search.+static AR: &[(&str, &str)] = &[+ ("16:9 (Wide)", "16:9 (عريض)"),+ ("21:9 (Wide)", "21:9 (عريض)"),+ ("2:3 (Tall)", "2:3 (طويل)"),+ ("3:2 (Wide)", "3:2 (عريض)"),+ ("3:4 (Tall)", "3:4 (طويل)"),+ ("3D Object", "كائن ثلاثي الأبعاد"),+ ("3D Stage", "مسرح ثلاثي الأبعاد"),+ ("3D World", "عالم ثلاثي الأبعاد"),+ ("4:3 (Wide)", "4:3 (عريض)"),+ ("4:5 (Tall)", "4:5 (طويل)"),+ ("9:16 (Tall)", "9:16 (طويل)"),+ ("9:21 (Tall)", "9:21 (طويل)"),+ ("A reference image is missing its link. Please add it again.", "صورة مرجعية واحدة فقدت رابطها. أضفها من جديد."),+ ("Accepts {} files", "يقبل ملفات {}"),+ ("Add a prompt, then generate", "أضف مطالبة نصية ثم أنشئ"),+ ("Add a prompt, then generate.", "أضف مطالبة نصية ثم أنشئ."),+ ("Add a starting image before generating", "أضف صورة بداية قبل الإنشاء"),+ ("Add audio", "إضافة صوت"),+ ("Add your {} account to ArtCraft!", "أضف حسابك في {} إلى ArtCraft!"),+ ("Almost done...", "أوشك على الانتهاء..."),+ ("Approve the sign-in in your browser and check the code matches. This window updates by itself.", "وافق على تسجيل الدخول في متصفحك وتأكد من تطابق الرمز. تتحدّث هذه النافذة تلقائياً."),+ ("ArtCraft native {}", "تطبيق ArtCraft الأصلي {}"),+ ("Audio", "صوت"),+ ("Audio Support", "دعم الصوت"),+ ("Audio Track ({}/{})", "مقطع صوتي ({}/{})"),+ ("Audio generation enqueued!", "أُدرج إنشاء الصوت في قائمة الانتظار!"),+ ("Audio references aren't available here", "مراجع الصوت غير متاحة هنا"),+ ("Audio track required", "مطلوب مقطع صوتي"),+ ("Audio {}", "صوت {}"),+ ("Audio {} \u{b7} {}s", "صوت {} \u{b7} {} ث"),+ ("Auto", "تلقائي"),+ ("Auto BPM", "BPM تلقائي"),+ ("Auto BPM is left to the model", "يُترك تحديد BPM للنموذج"),+ ("Auto BPM \u{b7} Auto", "BPM تلقائي \u{b7} تلقائي"),+ ("Auto BPM \u{b7} Cm", "BPM تلقائي \u{b7} دو صغير"),+ ("BG Remove", "إزالة الخلفية"),+ ("Beat & Key", "الإيقاع والمقام"),+ ("Buy credits", "شراء أرصدة"),+ ("Capable tools for artists.", "أدوات قديرة للفنانين."),+ ("Character name", "اسم الشخصية"),+ ("Character \u{201c}{}\u{201d} failed to create", "تعذّر إنشاء الشخصية \u{201c}{}\u{201d}"),+ ("Character \u{201c}{}\u{201d} is being created", "يجري إنشاء الشخصية \u{201c}{}\u{201d}"),+ ("Characters", "الشخصيات"),+ ("Choose an image, add a prompt, then generate", "اختر صورة، وأضف مطالبة نصية، ثم أنشئ"),+ ("Clear all", "مسح الكل"),+ ("Clear all?", "مسح الكل؟"),+ ("Clear style", "مسح النمط"),+ ("Close", "إغلاق"),+ ("Confirmation code", "رمز التأكيد"),+ ("Connect account", "ربط الحساب"),+ ("Copy", "نسخ"),+ ("Copy prompt", "نسخ المطالبة النصية"),+ ("Could not load the original prompt.", "تعذّر تحميل المطالبة النصية الأصلية."),+ ("Couldn't load models: {}", "تعذّر تحميل النماذج: {}"),+ ("Couldn't load your library: {}", "تعذّر تحميل مكتبتك: {}"),+ ("Couldn't open the file: {}", "تعذّر فتح الملف: {}"),+ ("Couldn't open the sign-in window: {}", "تعذّر فتح نافذة تسجيل الدخول: {}"),+ ("Couldn't open the video: {}", "تعذّر فتح الفيديو: {}"),+ ("Couldn't read {}: {}", "تعذّرت قراءة {}: {}"),+ ("Couldn't start generation - the service is temporarily unavailable. Try again shortly.", "تعذّر بدء الإنشاء - الخدمة غير متاحة مؤقتاً. حاول مجدداً بعد قليل."),+ ("Create Audio", "إنشاء صوت"),+ ("Create Image", "إنشاء صورة"),+ ("Create New", "إنشاء جديد"),+ ("Create Video", "إنشاء فيديو"),+ ("Create account", "إنشاء حساب"),+ ("Data folder", "مجلد البيانات"),+ ("Delete character", "حذف الشخصية"),+ ("Delete character?", "حذف الشخصية؟"),+ ("Delete this media?", "حذف هذه الوسائط؟"),+ ("Describe a scene. See it in motion.", "صِف مشهداً. وشاهده يتحرك."),+ ("Describe a song, a sound, or a sample", "صِف أغنية أو صوتاً أو عيّنة"),+ ("Describe a song, a sound, or a sample.", "صِف أغنية أو صوتاً أو عيّنة."),+ ("Describe anything. See it in seconds.", "صِف أي شيء. وشاهده خلال ثوانٍ."),+ ("Describe the music or sound you want", "صِف الموسيقى أو الصوت الذي تريد"),+ ("Describe the music or sound you want...", "صِف الموسيقى أو الصوت الذي تريده..."),+ ("Describe what you want in the image...", "صِف ما تريد رؤيته في الصورة..."),+ ("Describe what you want to happen in the video...", "صِف ما تريد أن يحدث في الفيديو..."),+ ("Description...", "الوصف..."),+ ("Deselect all", "إلغاء تحديد الكل"),+ ("Download", "تنزيل"),+ ("Downloaded {}", "تم تنزيل {}"),+ ("Drop to add references", "أفلِت لإضافة مراجع"),+ ("Drop to set your frames", "أفلِت لتعيين الإطارات"),+ ("Edit Character", "تحرير الشخصية"),+ ("Edit Image", "تحرير الصورة"),+ ("Edit Video", "تحرير الفيديو"),+ ("Edit character", "تحرير الشخصية"),+ ("English", "English"),+ ("Enter to generate", "Enter للإنشاء"),+ ("Exit selection", "إنهاء التحديد"),+ ("FAL API key", "مفتاح API لـ FAL"),+ ("FAL API key saved", "حُفظ مفتاح API لـ FAL"),+ ("Failed to clear provider credential: {}", "فشل مسح اعتماد المزوّد: {}"),+ ("Failed to save API key: {}", "فشل حفظ مفتاح API: {}"),+ ("Failed to start audio generation", "فشل بدء إنشاء الصوت"),+ ("Failed to start image generation. Please try again.", "فشل بدء إنشاء الصورة. حاول مجدداً."),+ ("Failed to start video generation. Please try again.", "فشل بدء إنشاء الفيديو. حاول مجدداً."),+ ("Failed to upload the reference image: {}", "فشل رفع الصورة المرجعية: {}"),+ ("Failed to upload video. Please upload an MP4 file.", "فشل رفع الفيديو. يرجى رفع ملف MP4."),+ ("Failed to upload {}: {}", "فشل رفع {}: {}"),+ ("First and last frames are already set", "الإطاران الأول والأخير معيّنان بالفعل"),+ ("First frame", "الإطار الأول"),+ ("First/Last frame", "الإطار الأول/الأخير"),+ ("Focus mode", "وضع التركيز"),+ ("Forgot password", "هل نسيت كلمة المرور؟"),+ ("Frame Extract", "استخراج الإطارات"),+ ("From library", "من المكتبة"),+ ("General", "عام"),+ ("Generate", "إنشاء"),+ ("Generate images, videos and audio with the best models, steer them with references and keyframes, and keep everything in your library.", "أنشئ صوراً وفيديوهات وصوتاً بأفضل النماذج، وجِهّها بالمراجع والإطارات المفتاحية، واحفظ كل شيء في مكتبتك."),+ ("Generating {} {} \u{b7} Results may appear one at a time", "جارٍ إنشاء {} {} \u{b7} قد تظهر النتائج واحدة تلو الأخرى"),+ ("Generating\u{2026}", "جارٍ الإنشاء\u{2026}"),+ ("Generation failed", "فشل الإنشاء"),+ ("Home", "الرئيسية"),+ ("Image", "صورة"),+ ("Image generation complete!", "اكتمل إنشاء الصورة!"),+ ("Image generation enqueued!", "أُدرج إنشاء الصورة في قائمة الانتظار!"),+ ("Image not available", "الصورة غير متاحة"),+ ("Image references aren't available here", "مراجع الصور غير متاحة هنا"),+ ("Images", "صور"),+ ("Input Mode", "وضع الإدخال"),+ ("Input mode", "وضع الإدخال"),+ ("Keep lyrics", "الاحتفاظ بالكلمات"),+ ("Language", "اللغة"),+ ("Last frame", "الإطار الأخير"),+ ("Last viewed", "آخر مشاهدة"),+ ("Library", "المكتبة"),+ ("Loading\u{2026}", "جارٍ التحميل\u{2026}"),+ ("Log out", "تسجيل الخروج"),+ ("Login to Midjourney", "تسجيل الدخول إلى Midjourney"),+ ("Make Video", "إنشاء فيديو"),+ ("Max {} audio track{}", "الحد الأقصى {} مقطعاً صوتياً{}"),+ ("Max {} image reference{}", "الحد الأقصى {} من الصور المرجعية{}"),+ ("Max {} {}{}", "الحد الأقصى {} من {}{}"),+ ("Max {} {}{} / {}s total", "الحد الأقصى {} من {}{} / {} ث إجمالاً"),+ ("Midjourney account", "حساب Midjourney"),+ ("Midjourney connected", "تم ربط Midjourney"),+ ("Midjourney didn't finish this job", "لم يُكمل Midjourney هذه المهمة"),+ ("Model", "النموذج"),+ ("Multi-media ref", "مرجع متعدد الوسائط"),+ ("Musical key", "المقام الموسيقي"),+ ("Name cannot be empty", "لا يمكن أن يكون الاسم فارغاً"),+ ("New Character", "شخصية جديدة"),+ ("Next item", "العنصر التالي"),+ ("No prompt", "لا مطالبة نصية"),+ ("No {} in your library yet", "لا {} في مكتبتك بعد"),+ ("No. of {}", "عدد {}"),+ ("Not enough credits for this generation", "لا أرصدة كافية لهذا الإنشاء"),+ ("Not logged in", "غير مسجّل الدخول"),+ ("Not set", "غير معيّن"),+ ("Number of generations", "عدد عمليات الإنشاء"),+ ("OFF", "متوقف"),+ ("ON", "مفعّل"),+ ("Omni Reference", "مرجع شامل"),+ ("Only image files can be added here", "يمكن إضافة ملفات الصور فقط هنا"),+ ("Only {} files can be added here", "يمكن إضافة ملفات {} هنا فقط"),+ ("Open data folder", "فتح مجلد البيانات"),+ ("Open in web app", "فتح في تطبيق الويب"),+ ("Open page again", "افتح الصفحة مجدداً"),+ ("Output Format", "صيغة الإخراج"),+ ("Output format", "صيغة الإخراج"),+ ("Paste your FAL API key", "الصق مفتاح API لـ FAL"),+ ("Paste your {} API key to generate with your own {} account. You can add all of your AI accounts to ArtCraft to use them all in one place and build the ultimate AI art tool.", "الصق مفتاح API الخاص بك في {} للإنشاء بحسابك الخاص في {}. يمكنك إضافة جميع حسابات الذكاء الاصطناعي لديك إلى ArtCraft لاستخدامها كلها من مكان واحد وبناء أداة الفن بالذكاء الاصطناعي المثالية."),+ ("Payment required", "الدفع مطلوب"),+ ("Pick from library", "اختيار من المكتبة"),+ ("Pitch", "درجة الصوت"),+ ("Play in your video player", "تشغيل في مشغل الفيديو لديك"),+ ("Play video previews", "تشغيل معاينات الفيديو"),+ ("Please add a starting frame image to generate video", "أضف صورة إطار بداية لإنشاء الفيديو"),+ ("Please enter a character name", "أدخل اسم الشخصية"),+ ("Please enter a prompt to generate video", "أدخل مطالبة نصية لإنشاء الفيديو"),+ ("Please select a model to generate video", "اختر نموذجاً لإنشاء الفيديو"),+ ("Please upload a reference image", "ارفع صورة مرجعية"),+ ("Please wait for your references to finish uploading", "انتظر اكتمال رفع مراجعك"),+ ("Previous item", "العنصر السابق"),+ ("Prompt", "المطالبة النصية"),+ ("Prompt copied", "تم نسخ المطالبة النصية"),+ ("Prompt exceeds the {} character limit for this model", "تتجاوز المطالبة النصية حد {} محرفاً لهذا النموذج"),+ ("Prompt too long", "المطالبة النصية طويلة جداً"),+ ("Provider accounts", "حسابات المزوّدين"),+ ("Remove", "إزالة"),+ ("Remove audio", "إزالة الصوت"),+ ("Remove image", "إزالة الصورة"),+ ("Removed audio reference \u{2014} it can't be combined with an image", "أُزيل المرجع الصوتي \u{2014} لا يمكن دمجه مع صورة"),+ ("Removed image reference \u{2014} it can't be combined with audio", "أُزيلت الصورة المرجعية \u{2014} لا يمكن دمجها مع الصوت"),+ ("Saved a screenshot to {}", "حُفظت لقطة الشاشة في {}"),+ ("See all", "عرض الكل"),+ ("Select Model", "اختيار النموذج"),+ ("Select Provider", "اختيار المزوّد"),+ ("Select items", "تحديد عناصر"),+ ("Select model", "اختيار النموذج"),+ ("Set up {}", "إعداد {}"),+ ("Settings", "الإعدادات"),+ ("Share link copied", "تم نسخ رابط المشاركة"),+ ("Sign in", "تسجيل الدخول"),+ ("Sign in to ArtCraft", "تسجيل الدخول إلى ArtCraft"),+ ("Sign in to create", "سجّل الدخول للإنشاء"),+ ("Sign in to see everything you've generated.", "سجّل الدخول لرؤية كل ما أنشأته."),+ ("Sign in to start", "سجّل الدخول للبدء"),+ ("Sign in with browser", "تسجيل الدخول عبر المتصفح"),+ ("Signed in as @{}", "مسجّل الدخول باسم @{}"),+ ("Signing in\u{2026}", "جارٍ تسجيل الدخول\u{2026}"),+ ("Sound: OFF", "الصوت: متوقف"),+ ("Sound: ON", "الصوت: مفعّل"),+ ("Starting frame required", "مطلوب إطار بداية"),+ ("Still thumbnails", "مصغّرات ثابتة"),+ ("Style", "النمط"),+ ("Style: e.g. dreamy synth-pop, female vocals", "النمط: مثال dreamy synth-pop، أصوات نسائية"),+ ("Swap frames", "تبديل الإطارات"),+ ("Text prompt violates content policy", "تنتهك المطالبة النصية سياسة المحتوى"),+ ("That file type isn't supported", "نوع الملف هذا غير مدعوم"),+ ("The Midjourney sign-in window is on {host}", "نافذة تسجيل الدخول إلى Midjourney على {host}"),+ ("This clears the prompt and also removes the attached references.", "يمسح هذا المطالبةَ النصية ويزيل أيضاً المراجع المرفقة."),+ ("This model can't generate from text alone - add a starting frame to animate your prompt.", "لا يستطيع هذا النموذج الإنشاء من نص وحده - أضف إطار بداية لتحريك مطالبتك."),+ ("This tool is coming to the native app. You can use it in the ArtCraft web app in the meantime.", "هذه الأداة قادمة إلى التطبيق الأصلي. يمكنك استخدامها في تطبيق ArtCraft على الويب في هذه الأثناء."),+ ("This will permanently remove the media from your library. This action cannot be undone.", "سيؤدي هذا إلى إزالة الوسائط نهائياً من مكتبتك. لا يمكن التراجع عن هذا الإجراء."),+ ("Toggle Sidebar", "إظهار/إخفاء الشريط الجانبي"),+ ("Too spicy", "محتوى غير لائق"),+ ("Total audio duration cannot exceed {}s", "لا يمكن أن تتجاوز إجمالي مدة الصوت {} ثانية"),+ ("Total {} duration cannot exceed {}s", "لا يمكن أن يتجاوز إجمالي مدة {} {} ثانية"),+ ("Upload reference image", "رفع صورة مرجعية"),+ ("Uploading\u{2026}", "جارٍ الرفع\u{2026}"),+ ("Use @Image1, @Video1, @Audio1... to reference uploads in prompt...", "استخدم @Image1 و@Video1 و@Audio1... للإشارة إلى الملفات المرفوعة داخل المطالبة..."),+ ("Use selected", "استخدام المحدد"),+ ("Use your ArtCraft account to generate images and videos.", "استخدم حسابك في ArtCraft لإنشاء الصور والفيديوهات."),+ ("Username or email", "اسم المستخدم أو البريد الإلكتروني"),+ ("Video", "فيديو"),+ ("Video generation enqueued!", "أُدرج إنشاء الفيديو في قائمة الانتظار!"),+ ("Video or audio", "فيديو أو صوت"),+ ("Videos", "فيديوهات"),+ ("Videos (MP4)", "فيديوهات (MP4)"),+ ("View full size", "عرض بالحجم الكامل"),+ ("Volume", "مستوى الصوت"),+ ("When on, pressing Enter submits the prompt and Shift+Enter adds a new line. When off (default), both Enter and Shift+Enter add a new line - use only the button to submit.", "عند التفعيل، يرسل مفتاح Enter المطالبة النصية ويضيف Shift+Enter سطراً جديداً. عند التعطيل (الافتراضي)، يضيف كلا المفتاحين سطراً جديداً - استخدم زر الإنشاء وحده للإرسال."),+ ("You can add your {} account to ArtCraft by simply logging in. Use your credits and account directly within ArtCraft. You can add all of your AI accounts to ArtCraft to use them all in one place and build the ultimate AI art tool.", "يمكنك إضافة حسابك في {} إلى ArtCraft بمجرد تسجيل الدخول. استخدم أرصدتك وحسابك مباشرة داخل ArtCraft. يمكنك إضافة جميع حسابات الذكاء الاصطناعي لديك إلى ArtCraft لاستخدامها كلها من مكان واحد وبناء أداة الفن بالذكاء الاصطناعي المثالية."),+ ("Your credit balance \u{b7} Buy credits", "رصيدك \u{b7} شراء أرصدة"),+ ("Your generations will appear here.", "ستظهر إنشاءاتك هنا."),+ ("audio", "صوت"),+ ("audio clip", "مقطع صوتي"),+ ("audio clips", "مقاطع صوتية"),+ ("audio model", "نموذج صوتي"),+ ("audio or image", "صوت أو صورة"),+ ("day", "يوم"),+ ("disabled without a prompt", "معطّل بلا مطالبة نصية"),+ ("file", "ملف"),+ ("hour", "ساعة"),+ ("image", "صورة"),+ ("image reference", "صورة مرجعية"),+ ("images", "صور"),+ ("just now", "الآن"),+ ("minute", "دقيقة"),+ ("month", "شهر"),+ ("reference", "مرجع"),+ ("required", "مطلوب"),+ ("video", "فيديو"),+ ("videos", "فيديوهات"),+ ("year", "سنة"),+ ("{} Generation", "إنشاء {}"),+ ("{} credits", "الأرصدة: {}"),+ ("{} credit{} cost", "التكلفة: {} رصيد{}"),+ ("{} generation complete!", "اكتمل إنشاء {}!"),+ ("{} isn't a supported file", "{} ليس ملفاً مدعوماً"),+ ("{} needs an audio track to work from \u{2014} add one first", "{} يحتاج مقطعاً صوتياً يعمل منه \u{2014} أضف واحداً أولاً"),+ ("{} references aren't available here", "مراجع {} غير متاحة هنا"),+ ("{} reference{}", "{} من المراجع{}"),+ ("{} selected", "{} محدد"),+ ("{} {}{} ago", "قبل {} {}{}"),+ ("{} \u{b7} {}/{}s", "{} \u{b7} {}/{} ث"),+ ("{} \u{2014} add one first", "{} \u{2014} أضف واحداً أولاً"),+ ("{}/{} selected", "{}/{} محدد"),+ ("{}: {}", "{}: {}"),+ ("{}s", "{} ث"),+ ("~{}h", "~{} س"),+ ("~{}h {}m", "~{} س {} د"),+ ("~{}m", "~{} د"),+ ("~{}s", "~{} ث"),+ ("العربية", "العربية"),+ ("\u{201c}{}\u{201d} will be removed from your characters.", "\u{201c}{}\u{201d} ستُزال من شخصياتك."),+];++fn tr_ar_index(s: &str) -> Option<usize> {+ AR.binary_search_by(|(k, _)| (*k).cmp(s)).ok()+}++/// The dictionary's Arabic side in visual order (`bidi.rs`), built once and shared by every+/// `t()` call: the runs left to right as a right-to-left reader meets them, the letters+/// inside each run in logical order for the shaper.+fn visual_ar() -> &'static [String] {+ static VISUAL: OnceLock<Vec<String>> = OnceLock::new();+ VISUAL.get_or_init(|| AR.iter().map(|(_, ar)| bidi::visual_rtl(ar)).collect())+}++#[cfg(test)]+mod tests {+ use super::*;++ /// The `{}` (and `{:…}`) placeholders of a pattern, in order.+ fn placeholders(s: &str) -> Vec<&str> {+ let mut out = Vec::new();+ let b = s.as_bytes();+ let mut i = 0;+ while i < b.len() {+ if b[i] == b'{' {+ if let Some(e) = s[i + 1..].find('}') {+ out.push(&s[i..i + 2 + e]);+ i += 2 + e;+ continue;+ }+ }+ i += 1;+ }+ out+ }++ #[test]+ fn translations_keep_placeholders() {+ for (en, ar) in AR {+ assert_eq!(placeholders(en), placeholders(ar), "placeholder mismatch: {en}");+ }+ }++ #[test]+ fn unknown_strings_fall_back_to_english() {+ set_language(Language::Arabic);+ assert_eq!(t("Not in the dictionary"), "Not in the dictionary");+ assert_eq!(t("Nano Banana Pro"), "Nano Banana Pro");+ set_language(Language::English);+ }++ /// Arabic's lookups come back in visual order (`bidi.rs`): the runs in the order egui+ /// draws them, the letters inside each run in logical order for the shaper.+ #[test]+ fn arabic_lookups_read_right_to_left() {+ set_language(Language::Arabic);+ assert_eq!(t("Sign in"), "الدخول تسجيل");+ assert_eq!(t("Settings"), "الإعدادات");+ set_language(Language::English);+ }++ #[test]+ fn tf_substitutes_in_order() {+ set_language(Language::Arabic);+ let out = tf("Signed in as @{}", &["mira".to_owned()]);+ assert!(out.contains("@mira"), "{out}");+ assert!(!out.contains("{}"), "{out}");+ let two = tf("Couldn't read {}: {}", &["a.png".to_owned(), "gone".to_owned()]);+ assert!(two.contains("a.png") && two.contains("gone"), "{two}");+ set_language(Language::English);+ assert_eq!(tf("{} of {}", &["1".to_owned(), "2".to_owned()]), "1 of 2");+ }++ /// The dictionary is a binary-searched static: it MUST stay sorted, or lookups+ /// silently miss. (This test would have caught an unsorted merge.)+ #[test]+ fn the_arabic_dictionary_stays_sorted_and_searchable() {+ for w in AR.windows(2) {+ assert!(w[0].0 < w[1].0, "dictionary out of order: {:?} >= {:?}", w[0].0, w[1].0);+ }+ // every English key is found through the binary search itself:+ for (k, v) in AR {+ let looked = AR.binary_search_by(|(e, _)| (*e).cmp(k));+ assert!(looked.is_ok(), "binary search cannot find {k:?}");+ assert_eq!(AR[looked.unwrap()].1, *v);+ }+ }++ #[test]+ fn spot_translations_follow_office_terminology() {+ assert_eq!(t("Sign in"), "Sign in");+ set_language(Language::Arabic);+ assert_eq!(t("Sign in"), "تسجيل الدخول");+ assert_eq!(t("Create Image"), "إنشاء صورة");+ assert_eq!(t("Aspect Ratio"), "نسبة العرض إلى الارتفاع");+ assert_eq!(t("Settings"), "الإعدادات");+ assert_eq!(t("Generate"), "إنشاء");+ assert_eq!(t("Library"), "المكتبة");+ assert_eq!(t("ON"), "مفعّل");+ assert_eq!(t("Download"), "تنزيل");+ set_language(Language::English);+ assert_eq!(t("Settings"), "Settings");+ }++ /// Any `ar-XX` region resolves to Arabic, like the other Craft apps.+ #[test]+ fn arabic_locale_tags_resolve() {+ assert_eq!(Language::from_code("ar"), Language::Arabic);+ assert_eq!(Language::from_code("ar-SA"), Language::Arabic);+ assert_eq!(Language::from_code("ar_EG"), Language::Arabic);+ assert_eq!(Language::from_code("en"), Language::English);+ }+}new file mode 100644@@ -0,0 +1,204 @@+//! egui lays a label out run by run — every run one font, shaped on its own — and puts the runs+//! left to right in the order it met them. It does not run the Unicode bidirectional algorithm+//! (its text code still carries `TODO(emilk): heed bidi characters`), so a right-to-left label+//! reads with its words in reverse order: egui shapes each Arabic word correctly once the+//! language's system font has loaded, but it draws the first word at the left of the line, and+//! the reader meets the second one first.+//!+//! This module reorders a translation into *visual order* before egui sees it, the technique+//! immediate-mode UIs use until their text stack grows bidi support: the label's runs come out+//! in the order they must appear on screen, left to right, while the characters inside each run+//! stay in logical order, because the shaper reverses a right-to-left run itself (harfrust+//! picks the direction from the script). Brackets are mirrored here too, as UAX #9 L4 asks:+//! egui draws the glyph of the character it is handed.+//!+//! Only catalog lookups go through here, so what a user types into a field keeps its logical+//! order — the order the clipboard and search expect. A label long enough to wrap still wraps+//! from its visual end (egui wraps runs as it meets them), and the mirrored layout of the+//! widgets is a separate, larger step; this pass is what an Arabic interface needs until egui+//! grows bidi of its own.++use unicode_bidi::{BidiClass, BidiInfo, Level};++/// A character the shaper lays out as part of a right-to-left run: a strong right-to-left+/// character (Arabic and Hebrew letters) or a mark riding on one. The stretches these form are+/// emitted in logical order; every other character keeps the place the reordering gave it.+fn is_rtl_shaped(c: char) -> bool {+ matches!(unicode_bidi::bidi_class(c), BidiClass::R | BidiClass::AL | BidiClass::NSM)+}++/// The glyph a bracket has to show in a right-to-left context: egui draws the glyph of the+/// character it is given, so the mirror pair has to be swapped in the text itself. The pairs+/// come from `BidiMirroring.txt` — the ones an interface label can hold, guillemets+/// included. Curly brackets are left alone: in an interface label they are almost always a+/// `{placeholder}`'s own syntax, and mirroring them would break the fill.+fn mirror(c: char) -> char {+ match c {+ '(' => ')',+ ')' => '(',+ '[' => ']',+ ']' => '[',+ '<' => '>',+ '>' => '<',+ '«' => '»',+ '»' => '«',+ '‹' => '›',+ '›' => '‹',+ _ => c,+ }+}++/// `label` in logical order, rearranged for egui's run-by-run, left-to-right layout: the runs in+/// visual order (left to right), the characters inside each run in logical order for the shaper,+/// brackets mirrored. Lines are reordered on their own (`\n` ends one). A `{placeholder}` is the+/// label's own syntax: it rides through the pass as one character, so the reordering can+/// neither split, reverse nor mirror it, and every placeholder comes back exactly as written.+/// A label with nothing right-to-left in it comes back as it was.+pub fn visual_rtl(label: &str) -> String {+ if !label.chars().any(is_rtl_shaped) {+ return label.into();+ }+ // Each placeholder becomes one private-use character (a left-to-right one, like the name+ // inside it) for the pass, and is restored afterwards; the mapping is per placeholder, so+ // a reordered sentence still finds its own.+ let mut tokens: Vec<&str> = Vec::new();+ let mut guarded = String::with_capacity(label.len());+ let mut rest = label;+ while let Some(open) = rest.find('{') {+ guarded.push_str(&rest[..open]);+ let after = &rest[open + 1..];+ if let Some(close) = after.find('}') {+ tokens.push(&rest[open..open + close + 2]);+ guarded.push(char::from_u32(0xE000 + tokens.len() as u32 - 1).unwrap_or('\u{E000}'));+ rest = &after[close + 1..];+ } else {+ guarded.push('{');+ rest = after;+ }+ }+ guarded.push_str(rest);+ let mut out = String::with_capacity(label.len());+ let mut first = true;+ for line in guarded.split('\n') {+ if !first {+ out.push('\n');+ }+ first = false;+ visual_rtl_line(line, &mut out);+ }+ if tokens.is_empty() {+ return out;+ }+ let mut restored = String::with_capacity(out.len());+ for c in out.chars() {+ let k = c as u32;+ if (0xE000..0xE000 + tokens.len() as u32).contains(&k) {+ restored.push_str(tokens[(k - 0xE000) as usize]);+ } else {+ restored.push(c);+ }+ }+ restored+}++/// One line of [`visual_rtl`] (no `\n` in it), appended to `out`.+fn visual_rtl_line(line: &str, out: &mut String) {+ let bidi = BidiInfo::new(line, Some(Level::rtl()));+ let Some(para) = bidi.paragraphs.first() else {+ out.push_str(line);+ return;+ };+ let chars: Vec<char> = line.chars().collect();+ let levels = bidi.reordered_levels_per_char(para, para.range.clone());+ // The line's characters in visual order, left to right (UAX #9 L1–L2).+ let order = BidiInfo::reorder_visual(&levels);+ let mut p = 0;+ while p < order.len() {+ let i = order[p];+ if is_rtl_shaped(chars[i]) {+ // One shaping run: a stretch of right-to-left characters, contiguous in the line,+ // whatever order the reordering brought it in (descending in a right-to-left+ // context, ascending inside a left-to-right island). Emitted in logical order,+ // which is ascending.+ let (mut lo, mut hi) = (i, i);+ let mut end = p + 1;+ loop {+ if let Some(&j) = order.get(end)+ && is_rtl_shaped(chars[j])+ && (j + 1 == lo || j == hi + 1)+ {+ lo = lo.min(j);+ hi = hi.max(j);+ end += 1;+ } else {+ break;+ }+ }+ let mut j = lo;+ while j <= hi {+ out.push(chars[j]);+ j += 1;+ }+ p = end;+ } else {+ // A left-to-right run — a Latin word, a number — or a character between runs: a+ // space, an ellipsis. It stands where the reordering put it; a bracket in a+ // right-to-left context shows its mirror.+ let c = chars[i];+ out.push(if levels[i].is_rtl() { mirror(c) } else { c });+ p += 1;+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn runs_in_visual_order_letters_in_logical_order() {+ // The first word of a sentence ends up rightmost; each word still reads correctly.+ assert_eq!(visual_rtl("إعداد المستند"), "المستند إعداد");+ assert_eq!(visual_rtl("تحرير لوحات الرسم"), "الرسم لوحات تحرير");+ // One run (one word) is left as it is: the shaper reverses its letters itself.+ assert_eq!(visual_rtl("المعاينة"), "المعاينة");+ // A mark rides on its letter inside the run.+ assert_eq!(visual_rtl("عنصراً جديد"), "جديد عنصراً");+ }++ #[test]+ fn left_to_right_islands_keep_their_own_order() {+ // A left-to-right run keeps its own order, as one unit, in its place in the sentence;+ // the ellipsis of a dialog command ends up at the reading end (the left).+ assert_eq!(visual_rtl("حفظ كـ PDF…"), "…PDF كـ حفظ");+ assert_eq!(visual_rtl("تصدير كـ SVG"), "SVG كـ تصدير");+ // A number between Arabic words reads left to right, in its place in the sentence.+ assert_eq!(visual_rtl("إنشاء 3 نسخ"), "نسخ 3 إنشاء");+ // A `{placeholder}` is the label's own syntax: it rides through as one unit, in the+ // order it was written, and never mirrored.+ assert_eq!(visual_rtl("لون مميز {n}"), "{n} مميز لون");+ assert_eq!(visual_rtl("جارٍ تنزيل {done} من {total}"), "{total} من {done} تنزيل جارٍ");+ }++ #[test]+ fn brackets_mirror() {+ assert_eq!(visual_rtl("حفظ (النسخة الحالية)"), "(الحالية النسخة) حفظ");+ // Guillemets mirror too (BidiMirroring.txt): quotes and breadcrumb separators point the+ // reading way, not the way they were typed.+ assert_eq!(visual_rtl("«ملف › الصفحة الرئيسية»"), "«الرئيسية الصفحة ‹ ملف»");+ assert_eq!(visual_rtl("«جديد…» يبدأ نسخة"), "نسخة يبدأ «…جديد»");+ }++ #[test]+ fn lines_are_reordered_on_their_own() {+ assert_eq!(visual_rtl("سطر أول\nسطر ثانٍ"), "أول سطر\nثانٍ سطر");+ assert_eq!(visual_rtl("سطر أول\nPDF"), "أول سطر\nPDF");+ }++ #[test]+ fn nothing_right_to_left_comes_back_as_it_was() {+ assert_eq!(visual_rtl("PDF"), "PDF");+ assert_eq!(visual_rtl("Plain label, 3 of them."), "Plain label, 3 of them.");+ assert_eq!(visual_rtl(""), "");+ }+}@@ -5,6 +5,7 @@ mod app; mod backend; mod feed;+mod i18n; mod models; mod overlays; mod pages;@@ -39,7 +39,7 @@ impl LoginDialog { }); }); ui.add_space(4.0);- ui.label(egui::RichText::new("Use your ArtCraft account to generate images and videos.").color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::t("Use your ArtCraft account to generate images and videos.")).color(theme::MUTED)); ui.add_space(16.0); let width = ui.available_width(); match &self.device {@@ -48,7 +48,7 @@ impl LoginDialog { ui.add_space(6.0); ui.label(egui::RichText::new(code).size(28.0).family(theme::mono_bold()).color(theme::INK).extra_letter_spacing(4.0)); ui.add_space(6.0);- ui.label(egui::RichText::new("Approve the sign-in in your browser and check the code matches. This window updates by itself.").color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::t("Approve the sign-in in your browser and check the code matches. This window updates by itself.")).color(theme::MUTED)); ui.add_space(10.0); ui.horizontal(|ui| { if widgets::button(ui, Some(Icon::ExternalLink), "Open page again", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {@@ -111,7 +111,7 @@ pub enum SettingsAction { /// The settings dialog (Settings → Misc and Accounts in the webapp). `fal_key` is the key being /// typed.-pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, username: Option<&str>, credits: Option<u64>, accounts: &AccountsView, fal_key: &mut String, data_root: &std::path::Path) -> Option<SettingsAction> {+pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, username: Option<&str>, credits: Option<u64>, accounts: &AccountsView, fal_key: &mut String, data_root: &std::path::Path, language: &mut crate::i18n::Language) -> Option<SettingsAction> { let mut action = None; let close = widgets::modal(ctx, Id::new("settings"), 520.0, |ui| { ui.horizontal(|ui| {@@ -128,8 +128,8 @@ pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, userna ui.horizontal(|ui| { ui.vertical(|ui| { ui.set_width(400.0);- ui.label(egui::RichText::new("Enter to generate").size(14.0).family(theme::medium()));- ui.label(egui::RichText::new("When on, pressing Enter submits the prompt and Shift+Enter adds a new line. When off (default), both Enter and Shift+Enter add a new line - use only the button to submit.").size(12.5).color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::t("Enter to generate")).size(14.0).family(theme::medium()));+ ui.label(egui::RichText::new(crate::i18n::t("When on, pressing Enter submits the prompt and Shift+Enter adds a new line. When off (default), both Enter and Shift+Enter add a new line - use only the button to submit.")).size(12.5).color(theme::MUTED)); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if switch(ui, *enter_to_generate).clicked() {@@ -137,14 +137,30 @@ pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, userna } }); });+ ui.add_space(6.0);+ ui.horizontal(|ui| {+ ui.vertical(|ui| {+ ui.set_width(400.0);+ ui.label(egui::RichText::new(crate::i18n::t("Language")).size(14.0).family(theme::medium()));+ });+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+ for l in crate::i18n::Language::ALL {+ let name = l.name();+ if widgets::ghost(ui, Icon::Globe, Some(name), "Language", *language == *l).clicked() {+ *language = *l;+ crate::i18n::set_language(*l);+ }+ }+ });+ }); ui.add_space(18.0); widgets::hud(ui, "Account", theme::FAINT); ui.add_space(6.0); match username { Some(user) => {- ui.label(egui::RichText::new(format!("Signed in as @{user}")).size(14.0));+ ui.label(egui::RichText::new(crate::i18n::tf("Signed in as @{}", &[user.to_string()])).size(14.0)); if let Some(c) = credits {- ui.label(egui::RichText::new(format!("{} credits", group_thousands(c))).size(12.5).color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::tf("{} credits", &[group_thousands(c).to_string()])).size(12.5).color(theme::MUTED)); } ui.add_space(8.0); ui.horizontal(|ui| {@@ -171,7 +187,7 @@ pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, userna ui.add_space(18.0); widgets::hud(ui, "About", theme::FAINT); ui.add_space(6.0);- ui.label(egui::RichText::new(format!("ArtCraft native {}", env!("CARGO_PKG_VERSION"))).size(13.0).color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::tf("ArtCraft native {}", &[env!("CARGO_PKG_VERSION").to_string()])).size(13.0).color(theme::MUTED)); if widgets::button(ui, Some(Icon::Folder), "Open data folder", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() { if let Err(err) = open::that(data_root) { log::warn!("Couldn't open {}: {err}", data_root.display());@@ -105,7 +105,7 @@ impl CharactersModal { let dismissed = widgets::modal(ctx, Id::new("character-delete"), 420.0, |ui| { ui.label(widgets::heading("Delete character?", 20.0)); ui.add_space(8.0);- ui.label(egui::RichText::new(format!("\u{201c}{name}\u{201d} will be removed from your characters.")).color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::tf("\u{201c}{}\u{201d} will be removed from your characters.", &[name.to_string()])).color(theme::MUTED)); ui.add_space(16.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::button(ui, None, "Delete", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() {@@ -211,7 +211,7 @@ impl CharactersModal { let (r, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover()); thumbnail(ui, r, c.full_url().or_else(|| c.avatar_url()).as_deref(), cache); if let Some(url) = c.full_url() {- if ui.add(egui::Button::new(egui::RichText::new("View full size").size(12.0).color(theme::ACCENT_INK)).frame(false)).clicked() {+ if ui.add(egui::Button::new(egui::RichText::new(crate::i18n::t("View full size")).size(12.0).color(theme::ACCENT_INK)).frame(false)).clicked() { actions.push(CharactersAction::Preview(url)); } }@@ -270,7 +270,7 @@ impl CharactersModal { actions.push(CharactersAction::Upload { ref_id: img.id, path }); self.image = Some(img); },- Err(err) => actions.push(CharactersAction::Toast(format!("Failed to upload {}: {err}", crate::pages::common::file_name(&path)))),+ Err(err) => actions.push(CharactersAction::Toast(crate::i18n::tf("Failed to upload {}: {}", &[crate::pages::common::file_name(&path).to_string(), err.to_string()]))), } } }@@ -305,11 +305,11 @@ impl CharactersModal { fn fields(ui: &mut Ui, name: &mut String, description: &mut String) { widgets::hud(ui, "Character name", theme::FAINT); ui.add_space(4.0);- input_frame(ui, |ui| ui.add(egui::TextEdit::singleline(name).frame(egui::Frame::NONE).desired_width(f32::INFINITY).hint_text(egui::RichText::new("Character name").color(theme::FAINT))));+ input_frame(ui, |ui| ui.add(egui::TextEdit::singleline(name).frame(egui::Frame::NONE).desired_width(f32::INFINITY).hint_text(egui::RichText::new(crate::i18n::t("Character name")).color(theme::FAINT)))); ui.add_space(10.0); widgets::hud(ui, "Description", theme::FAINT); ui.add_space(4.0);- input_frame(ui, |ui| ui.add(egui::TextEdit::multiline(description).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(4).hint_text(egui::RichText::new("Description...").color(theme::FAINT))));+ input_frame(ui, |ui| ui.add(egui::TextEdit::multiline(description).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(4).hint_text(egui::RichText::new(crate::i18n::t("Description...")).color(theme::FAINT)))); } fn input_frame(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> egui::Response) {@@ -114,7 +114,7 @@ impl LibraryPicker { self.toggle(token); } if self.items.is_empty() && !self.loading && !self.has_more {- ui.painter().text(origin + vec2(w / 2.0, 80.0), Align2::CENTER_CENTER, format!("No {noun} in your library yet"), FontId::new(14.0, egui::FontFamily::Proportional), theme::MUTED);+ ui.painter().text(origin + vec2(w / 2.0, 80.0), Align2::CENTER_CENTER, crate::i18n::tf("No {} in your library yet", &[crate::i18n::t(noun)]), FontId::new(14.0, egui::FontFamily::Proportional), theme::MUTED); } if self.loading { icons::paint_spinner(ui, Rect::from_center_size(origin + vec2(w / 2.0, total + 24.0), vec2(24.0, 24.0)), theme::MUTED);@@ -124,7 +124,7 @@ impl LibraryPicker { }); ui.add_space(12.0); ui.horizontal(|ui| {- widgets::hud(ui, &format!("{}/{} selected", self.selected.len(), self.max), theme::MUTED);+ widgets::hud(ui, &crate::i18n::tf("{}/{} selected", &[self.selected.len().to_string(), self.max.to_string()]), theme::MUTED); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let any = !self.selected.is_empty(); if ui.add_enabled_ui(any, |ui| widgets::button(ui, None, "Use selected", widgets::Kind::Primary, 0.0, theme::CONTROL_H)).inner.clicked() {@@ -132,7 +132,7 @@ impl Lightbox { } if item.kind == MediaKind::Video { let play = Rect::from_center_size(rect.center(), vec2(72.0, 72.0));- let resp = ui.interact(play, Id::new(("lightbox-play", &item.token)), Sense::click()).on_hover_text("Play in your video player").on_hover_cursor(egui::CursorIcon::PointingHand);+ let resp = ui.interact(play, Id::new(("lightbox-play", &item.token)), Sense::click()).on_hover_text(crate::i18n::t("Play in your video player")).on_hover_cursor(egui::CursorIcon::PointingHand); ui.painter().circle_filled(play.center(), 36.0, Color32::from_black_alpha(if resp.hovered() { 200 } else { 150 })); icons::paint(ui.painter(), play.shrink(22.0).translate(vec2(3.0, 0.0)), Icon::Play, Color32::WHITE); if resp.clicked() {@@ -177,7 +177,7 @@ impl Lightbox { widgets::hud(ui, "Prompt", theme::FAINT); if let Some(t) = &text { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {- if ui.add(egui::Button::new(egui::RichText::new("Copy").size(11.0)).frame(false)).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {+ if ui.add(egui::Button::new(egui::RichText::new(crate::i18n::t("Copy")).size(11.0)).frame(false)).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { actions.push(LightboxAction::CopyPrompt(t.clone())); } });@@ -204,7 +204,7 @@ impl Lightbox { icons::paint_spinner(ui, rect, theme::FAINT); }, _ => {- ui.label(egui::RichText::new("No prompt").size(13.0).color(theme::FAINT));+ ui.label(egui::RichText::new(crate::i18n::t("No prompt")).size(13.0).color(theme::FAINT)); }, } ui.add_space(14.0);@@ -275,7 +275,7 @@ impl Lightbox { let close = widgets::modal(ctx, Id::new("lightbox-delete"), 420.0, |ui| { ui.label(widgets::heading("Delete this media?", 20.0)); ui.add_space(8.0);- ui.label(egui::RichText::new("This will permanently remove the media from your library. This action cannot be undone.").color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::t("This will permanently remove the media from your library. This action cannot be undone.")).color(theme::MUTED)); ui.add_space(16.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::button(ui, None, "Delete", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() {@@ -49,13 +49,13 @@ pub fn setup_dialog(ctx: &egui::Context, provider: Provider, fal_key: &mut Strin }); }); ui.add_space(6.0);- ui.label(widgets::heading(&format!("Set up {name}"), 26.0));+ ui.label(widgets::heading(&crate::i18n::tf("Set up {}", &[name.to_string()]), 26.0)); ui.add_space(4.0);- ui.label(egui::RichText::new(format!("Add your {name} account to ArtCraft!")).size(14.0).family(theme::medium()));+ ui.label(egui::RichText::new(crate::i18n::tf("Add your {} account to ArtCraft!", &[name.to_string()])).size(14.0).family(theme::medium())); ui.add_space(10.0); let description = match provider {- Provider::Fal => format!("Paste your {name} API key to generate with your own {name} account. You can add all of your AI accounts to ArtCraft to use them all in one place and build the ultimate AI art tool."),- _ => format!("You can add your {name} account to ArtCraft by simply logging in. Use your credits and account directly within ArtCraft. You can add all of your AI accounts to ArtCraft to use them all in one place and build the ultimate AI art tool."),+ Provider::Fal => crate::i18n::tf("Paste your {} API key to generate with your own {} account. You can add all of your AI accounts to ArtCraft to use them all in one place and build the ultimate AI art tool.", &[name.to_string(), name.to_string()]),+ _ => crate::i18n::tf("You can add your {} account to ArtCraft by simply logging in. Use your credits and account directly within ArtCraft. You can add all of your AI accounts to ArtCraft to use them all in one place and build the ultimate AI art tool.", &[name.to_string()]), }; ui.label(egui::RichText::new(description).size(13.0).color(theme::MUTED)); ui.add_space(16.0);@@ -64,7 +64,7 @@ pub fn setup_dialog(ctx: &egui::Context, provider: Provider, fal_key: &mut Strin ui.add_space(10.0); } let ready = provider != Provider::Fal || !fal_key.trim().is_empty();- if ui.add_enabled_ui(ready, |ui| widgets::button(ui, Some(Icon::ChevronRight), &format!("Set up {name}"), widgets::Kind::Primary, ui.available_width(), 40.0)).inner.clicked() {+ if ui.add_enabled_ui(ready, |ui| widgets::button(ui, Some(Icon::ChevronRight), &crate::i18n::tf("Set up {}", &[name.to_string()]), widgets::Kind::Primary, ui.available_width(), 40.0)).inner.clicked() { action = Some(if provider == Provider::Fal { SetupAction::SaveFalKey } else { SetupAction::ConnectMidjourney }); } });@@ -179,7 +179,7 @@ impl MidjourneyLogin { Some(err) => { let mut note = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(24.0)).layout(egui::Layout::top_down(egui::Align::Center))); note.add_space(rect.height() / 2.0 - 48.0);- note.label(egui::RichText::new(format!("Couldn't open the sign-in window: {err}")).size(13.0).color(theme::BAD));+ note.label(egui::RichText::new(crate::i18n::tf("Couldn't open the sign-in window: {}", &[err.to_string()])).size(13.0).color(theme::BAD)); }, None => icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(28.0, 28.0)), theme::MUTED), }@@ -138,6 +138,8 @@ impl FeedKey { /// and the prompt box (drawn by `prompt_box`) floating at the bottom centre. #[allow(clippy::too_many_arguments)] pub fn create_shell(ui: &mut Ui, env: &mut Env<'_>, kind: MediaKind, title: &str, subtitle: &str, feed: &mut FeedStore, box_height: &mut f32, prompt_box: impl FnOnce(&mut Ui, &mut Env<'_>)) {+ let title = crate::i18n::t(title);+ let subtitle = crate::i18n::t(subtitle); let area = ui.max_rect(); let bottom_offset = *box_height + 40.0; if feed.has_content() {@@ -197,7 +199,7 @@ pub fn selection_bar(ui: &Ui, area: Rect, bottom: f32, feed: &mut FeedStore, kin ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; ui.add_space(6.0);- ui.label(egui::RichText::new(format!("{} selected", feed.selected.len())).size(13.0).family(theme::medium()));+ ui.label(egui::RichText::new(crate::i18n::tf("{} selected", &[feed.selected.len().to_string()])).size(13.0).family(theme::medium())); let any = !feed.selected.is_empty(); if ui.add_enabled_ui(any, |ui| widgets::button(ui, Some(crate::ui::icons::Icon::Download), "Download", widgets::Kind::Secondary, 0.0, 30.0)).inner.clicked() { requests.push(AppRequest::Download(feed.selected.iter().cloned().collect()));@@ -270,7 +272,7 @@ pub fn upload_path(env: &mut Env<'_>, refs: &mut References, path: &Path, kind: item.preview = Some(key); }, Err(err) => {- env.toasts.error(format!("Couldn't read {}: {err}", file_name(path)));+ env.toasts.error(crate::i18n::tf("Couldn't read {}: {}", &[file_name(path).to_string(), err.to_string()])); return; }, }@@ -315,7 +317,7 @@ pub fn apply_upload(toasts: &mut Toasts, refs: &mut References, ref_id: u64, res Err(message) => { let kind = refs.find_mut(ref_id).map_or("file", |r| r.kind.noun()); refs.remove(ref_id);- toasts.error(format!("Failed to upload {kind}: {message}"));+ toasts.error(crate::i18n::tf("Failed to upload {}: {}", &[crate::i18n::t(kind), message.to_string()])); false }, }@@ -221,7 +221,7 @@ impl CreateAudioPage { self.make_room_for_image(env); common::upload_path(env, &mut self.refs, path, RefKind::Image, ImageSlot::Reference); } else if !paths.is_empty() {- env.toasts.error(format!("Only {} files can be added here", accepted_kinds(m)));+ env.toasts.error(crate::i18n::tf("Only {} files can be added here", &[crate::i18n::t(&accepted_kinds(m))])); } } @@ -236,7 +236,7 @@ impl CreateAudioPage { } let free = m.audio_refs_max.saturating_sub(self.refs.audios.len()); if paths.len() > free {- env.toasts.error(format!("Max {} audio track{}", m.audio_refs_max, if m.audio_refs_max == 1 { "" } else { "s" }));+ env.toasts.error(crate::i18n::tf("Max {} audio track{}", &[m.audio_refs_max.to_string(), (if m.audio_refs_max == 1 || crate::i18n::is_arabic() { "" } else { "s" }).to_string()])); } if free == 0 { return;@@ -307,7 +307,7 @@ impl CreateAudioPage { return; }; if self.missing_required_ref(m) {- env.toasts.error(format!("{} needs an audio track to work from \u{2014} add one first", m.name));+ env.toasts.error(crate::i18n::tf("{} needs an audio track to work from \u{2014} add one first", &[m.name.to_string()])); return; } if !env.signed_in {@@ -394,7 +394,7 @@ impl CreateAudioPage { fn audio_toolbar(ui: &mut Ui, caps: &AudioCaps, s: &mut AudioSettings) { let toggles = [(caps.instrumental, &mut s.instrumental, "audio-instrumental", Icon::MicOff, "Instrumental"), (caps.keep_lyrics, &mut s.keep_lyrics, "audio-keep-lyrics", Icon::Mic, "Keep lyrics"), (caps.loopable, &mut s.loopable, "audio-loop", Icon::Repeat, "Loop")]; for (supported, value, id, icon, label) in toggles {- if supported && pickers::toggle(ui, Id::new(id), icon, label, *value, &format!("{label}: {}", if *value { "ON" } else { "OFF" })) {+ if supported && pickers::toggle(ui, Id::new(id), icon, label, *value, &crate::i18n::tf("{}: {}", &[crate::i18n::t(label), crate::i18n::t(if *value { "ON" } else { "OFF" })])) { *value = !*value; } }@@ -410,7 +410,7 @@ fn audio_toolbar(ui: &mut Ui, caps: &AudioCaps, s: &mut AudioSettings) { fn beat_and_key(ui: &mut Ui, caps: &AudioCaps, s: &mut AudioSettings) { let mut parts = Vec::new(); if caps.bpm {- parts.push(s.bpm.map_or_else(|| "Auto BPM".to_owned(), |b| format!("{b} BPM")));+ parts.push(s.bpm.map_or_else(|| "Auto BPM".to_owned(), |b| crate::i18n::tf("{} BPM", &[b.to_string()]))); } if caps.musical_key { parts.push(musical_key(&s.musical_key).2.to_owned());@@ -425,7 +425,7 @@ fn beat_and_key(ui: &mut Ui, caps: &AudioCaps, s: &mut AudioSettings) { setting_name(ui, "BPM"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { setting_value(ui, &s.bpm.map_or_else(|| "Auto".to_owned(), |b| b.to_string()));- if s.bpm.is_some() && ui.add(egui::Button::new(egui::RichText::new("Auto").size(11.5).color(theme::ACCENT_INK)).frame(false)).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {+ if s.bpm.is_some() && ui.add(egui::Button::new(egui::RichText::new(crate::i18n::t("Auto")).size(11.5).color(theme::ACCENT_INK)).frame(false)).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { s.bpm = None; } });@@ -502,9 +502,9 @@ fn reference_row(ui: &mut Ui, m: &ModelInfo, refs: &References, playing: Option< ui.spacing_mut().item_spacing = vec2(8.0, 6.0); if m.audio_refs_max > 0 { icons::icon(ui, Icon::Music, 14.0, theme::MUTED);- ui.label(egui::RichText::new(format!("Audio Track ({}/{})", refs.audios.len(), m.audio_refs_max)).size(12.5).color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::tf("Audio Track ({}/{})", &[refs.audios.len().to_string(), m.audio_refs_max.to_string()])).size(12.5).color(theme::MUTED)); if m.audio.requires_audio_ref && refs.audios.is_empty() {- ui.label(egui::RichText::new("required").size(12.5).color(theme::BAD));+ ui.label(egui::RichText::new(crate::i18n::t("required")).size(12.5).color(theme::BAD)); } for (i, r) in refs.audios.iter().enumerate() { audio_tile(ui, i, r, playing == Some(r.id), actions);@@ -521,7 +521,7 @@ fn reference_row(ui: &mut Ui, m: &ModelInfo, refs: &References, playing: Option< } if m.image_refs_max > 0 { ui.add_space(8.0);- ui.label(egui::RichText::new("Image").size(12.5).color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::t("Image")).size(12.5).color(theme::MUTED)); match refs.images.first() { Some(r) => image_tile(ui, r, image, actions), None => {@@ -540,7 +540,7 @@ fn reference_row(ui: &mut Ui, m: &ModelInfo, refs: &References, playing: Option< /// One audio reference (`AudioRefTile`): play / stop, "Audio 1 · 12s", remove. fn audio_tile(ui: &mut Ui, index: usize, r: &RefMedia, playing: bool, actions: &mut Vec<RowAction>) { let uploading = r.status == RefStatus::Uploading;- let text = if uploading { format!("Audio {}", index + 1) } else { format!("Audio {} \u{b7} {}s", index + 1, r.duration_secs.round() as i64) };+ let text = if uploading { crate::i18n::tf("Audio {}", &[(index + 1).to_string()]) } else { crate::i18n::tf("Audio {} \u{b7} {}s", &[(index + 1).to_string(), (r.duration_secs.round() as i64).to_string()]) }; let galley = ui.painter().layout_no_wrap(text, FontId::new(12.5, theme::medium()), theme::INK); let (rect, _) = ui.allocate_exact_size(vec2(6.0 + 24.0 + 8.0 + galley.size().x + 6.0 + 20.0 + 6.0, theme::CONTROL_H), Sense::hover()); ui.painter().rect(rect, theme::RADIUS, theme::CONTROLS, Stroke::new(1.0, theme::LINE), egui::StrokeKind::Inside);@@ -692,7 +692,7 @@ fn accepted_kinds(m: &ModelInfo) -> String { } fn duration_message() -> String {- format!("Total audio duration cannot exceed {}s", MAX_REF_SECS as u32)+ crate::i18n::tf("Total audio duration cannot exceed {}s", &[(MAX_REF_SECS as u32).to_string()]) } /// What a failed generate tells the user: out of credits, an outage, or the server's message.@@ -190,7 +190,7 @@ impl CreateImagePage { (true, false) => "Video", _ => "Audio", };- env.toasts.error(format!("{what} references aren't available here"));+ env.toasts.error(crate::i18n::tf("{} references aren't available here", &[crate::i18n::t(what)])); } else if images.is_empty() { env.toasts.error("Only image files can be added here"); }@@ -220,7 +220,7 @@ impl CreateImagePage { } let limit = model.prompt_max; if editor::is_over_limit(&self.settings.prompt, limit) {- env.toasts.error(format!("Prompt exceeds the {} character limit for this model", limit.unwrap_or_default()));+ env.toasts.error(crate::i18n::tf("Prompt exceeds the {} character limit for this model", &[limit.unwrap_or_default().to_string()])); return; } if self.refs.any_uploading() {@@ -319,5 +319,5 @@ impl CreateImagePage { } fn max_images_message(max: usize) -> String {- format!("Max {max} image reference{}", if max == 1 { "" } else { "s" })+ crate::i18n::tf("Max {} image reference{}", &[max.to_string(), (if max == 1 || crate::i18n::is_arabic() { "" } else { "s" }).to_string()]) }@@ -236,7 +236,7 @@ impl CreateVideoPage { let model = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL).cloned(); for path in paths { let Some(kind) = RefKind::from_path(path) else {- env.toasts.error(format!("{} isn't a supported file", common::file_name(path)));+ env.toasts.error(crate::i18n::tf("{} isn't a supported file", &[common::file_name(path).to_string()])); continue; }; match (self.settings.input_mode, kind) {@@ -247,7 +247,7 @@ impl CreateVideoPage { None => env.toasts.error(frames_full_message(model.as_ref())), } },- (InputMode::Keyframe, _) => env.toasts.error(format!("{} references aren't available here", if kind == RefKind::Video { "Video" } else { "Audio" })),+ (InputMode::Keyframe, _) => env.toasts.error(crate::i18n::tf("{} references aren't available here", &[crate::i18n::t(if kind == RefKind::Video { "Video" } else { "Audio" })])), (InputMode::Reference, _) => { let Some(l) = limits else { continue;@@ -258,7 +258,7 @@ impl CreateVideoPage { RefKind::Audio => l.max_audios, }; if max == 0 {- env.toasts.error(format!("{}{} references aren't available here", kind.noun()[..1].to_uppercase(), &kind.noun()[1..]));+ env.toasts.error(crate::i18n::tf("{} references aren't available here", &[crate::i18n::t(kind.noun())])); } else if free_slots(&self.refs, kind, l) == 0 { let secs = if kind == RefKind::Video { l.max_video_secs } else { l.max_audio_secs }; env.toasts.error(max_message(kind, max, secs.filter(|_| kind != RefKind::Image)));@@ -281,7 +281,7 @@ impl CreateVideoPage { }; if self.refs.list(kind).iter().any(|r| r.id == ref_id) && self.refs.total_secs(kind) > f32::from(max) + 0.5 { self.refs.remove(ref_id);- env.toasts.error(format!("Total {} duration cannot exceed {max}s", kind.noun()));+ env.toasts.error(crate::i18n::tf("Total {} duration cannot exceed {}s", &[crate::i18n::t(kind.noun()), max.to_string()])); } } }@@ -304,7 +304,7 @@ impl CreateVideoPage { } let limit = model.prompt_max; if editor::is_over_limit(&self.settings.prompt, limit) {- env.toasts.error(format!("Prompt exceeds the {} character limit for this model", limit.unwrap_or_default()));+ env.toasts.error(crate::i18n::tf("Prompt exceeds the {} character limit for this model", &[limit.unwrap_or_default().to_string()])); return; } if self.needs_start_frame(model) {@@ -602,10 +602,10 @@ fn max_message(kind: RefKind, max: usize, secs: Option<f32>) -> String { RefKind::Video => "video", RefKind::Audio => "audio clip", };- let plural = if max == 1 { "" } else { "s" };+ let plural = if max == 1 || crate::i18n::is_arabic() { "" } else { "s" }; match secs {- Some(s) => format!("Max {max} {noun}{plural} / {}s total", s.round() as i64),- None => format!("Max {max} {noun}{plural}"),+ Some(s) => crate::i18n::tf("Max {} {}{} / {}s total", &[max.to_string(), crate::i18n::t(noun), plural.to_string(), (s.round() as i64).to_string()]),+ None => crate::i18n::tf("Max {} {}{}", &[max.to_string(), crate::i18n::t(noun), plural.to_string()]), } } @@ -23,7 +23,7 @@ pub fn home(ui: &mut Ui, signed_in: bool, requests: &mut Vec<AppRequest>) -> Opt ui.add_space(10.0); ui.label(widgets::heading("Capable tools for artists.", 52.0)); ui.add_space(10.0);- ui.label(egui::RichText::new("Generate images, videos and audio with the best models, steer them with references and keyframes, and keep everything in your library.").size(17.0).color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::t("Generate images, videos and audio with the best models, steer them with references and keyframes, and keep everything in your library.")).size(17.0).color(theme::MUTED)); ui.add_space(28.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 16.0;@@ -48,10 +48,11 @@ pub fn quality(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option< /// The number of generations (`GenerationCountPicker`): a quiet copy-icon button. pub fn count(ui: &mut Ui, id: Id, model: &ModelInfo, current: u16, noun: &str) -> Option<u16> {+ let noun = crate::i18n::t(noun); let options: Vec<PickOption<u16>> = model.batch_options().into_iter().map(|n| PickOption::new(n, n.to_string())).collect(); if options.len() <= 1 { ui.add_enabled_ui(false, |ui| crate::ui::widgets::ghost(ui, Icon::Copy, Some(¤t.to_string()), "Number of generations", false)); return None; }- pickers::option_picker(ui, id, Trigger::Ghost(Icon::Copy), &format!("No. of {noun}"), "Number of generations", ¤t, &options)+ pickers::option_picker(ui, id, Trigger::Ghost(Icon::Copy), &crate::i18n::tf("No. of {}", &[noun]), "Number of generations", ¤t, &options) }@@ -170,7 +170,7 @@ pub fn keyframe_cards(ui: &mut Ui, id: Id, refs: &References, show_last: bool, l Stroke::new(1.0, theme::fade(Color32::WHITE, if both { 0.2 } else { 0.1 })), ); icons::paint(p, swap.shrink(5.0), Icon::ArrowLeftRight, theme::fade(Color32::WHITE, if both { 1.0 } else { 0.5 }));- if both && resp.on_hover_text("Swap frames").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {+ if both && resp.on_hover_text(crate::i18n::t("Swap frames")).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { actions.push(DeckAction::SwapFrames); } slot_card(ui, id.with("last"), refs.last_frame.as_ref(), "Last frame", 6.0, ImageSlot::LastFrame, library, cache, &mut actions);@@ -220,9 +220,9 @@ fn expanded_panel(ui: &mut Ui, id: Id, refs: &References, items: &[&RefMedia], o if items.len() > 1 { ui.horizontal(|ui| { let n = items.len();- ui.label(egui::RichText::new(format!("{n} reference{}", if n == 1 { "" } else { "s" })).size(11.0).family(theme::medium()).color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::tf("{} reference{}", &[n.to_string(), (if n == 1 || crate::i18n::is_arabic() { "" } else { "s" }).to_string()])).size(11.0).family(theme::medium()).color(theme::MUTED)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {- let resp = ui.add(egui::Button::new(egui::RichText::new("Clear all").size(11.0)).frame(false));+ let resp = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::t("Clear all")).size(11.0)).frame(false)); if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { actions.push(DeckAction::ClearAll); }@@ -357,7 +357,7 @@ fn remove_button(ui: &mut Ui, id: Id, card: Rect, card_hovered: bool) -> bool { let fill = if resp.hovered() { theme::fade(theme::DANGER, 0.7) } else { Color32::from_black_alpha(128) }; ui.painter().circle_filled(rect.center(), 10.0, fill); icons::paint(ui.painter(), rect.shrink(5.0), Icon::X, Color32::WHITE);- resp.on_hover_text("Remove").on_hover_cursor(egui::CursorIcon::PointingHand).clicked()+ resp.on_hover_text(crate::i18n::t("Remove")).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() } /// The dashed "+" card that adds a reference (straightens and grows on hover).@@ -420,7 +420,7 @@ fn add_menu_entries(refs: &References, limits: &DeckLimits, open_kinds: &[RefKin .map(|&kind| { let count = format!("{}/{}", refs.list(kind).len(), limits.max(kind)); let hint = match limits.max_secs(kind) {- Some(max) => format!("{count} \u{b7} {}/{}s", refs.total_secs(kind).round() as i64, max.round() as i64),+ Some(max) => crate::i18n::tf("{} \u{b7} {}/{}s", &[count.to_string(), (refs.total_secs(kind).round() as i64).to_string(), (max.round() as i64).to_string()]), None => count, }; MenuGroup { kind, hint: Some(hint), slot, library: limits.library }@@ -168,7 +168,7 @@ pub fn show(ui: &mut Ui, prompt: &mut String, state: &mut EditorState, opts: &Ed if opts.fullscreen_button { let button = Rect::from_min_size(pos2(field_rect.right() - 24.0, field_rect.top()), vec2(24.0, 24.0));- let resp = ui.interact(button, id.with("fullscreen"), Sense::click()).on_hover_text("Focus mode").on_hover_cursor(egui::CursorIcon::PointingHand);+ let resp = ui.interact(button, id.with("fullscreen"), Sense::click()).on_hover_text(crate::i18n::t("Focus mode")).on_hover_cursor(egui::CursorIcon::PointingHand); let p = ui.painter(); p.rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH }); icons::paint(p, button.shrink(6.0), Icon::Maximize, if resp.hovered() { theme::INK } else { theme::DIM });@@ -183,8 +183,8 @@ pub fn show(ui: &mut Ui, prompt: &mut String, state: &mut EditorState, opts: &Ed let count = prompt.chars().count(); let over = limit.is_some_and(|max| count > max); let text = match limit {- Some(max) => format!("{count} / {max}"),- None => format!("{count} / \u{221e}"),+ Some(max) => crate::i18n::tf("{} / {}", &[count.to_string(), max.to_string()]),+ None => crate::i18n::tf("{} / \u{221e}", &[count.to_string()]), }; let color = if over { Color32::from_rgb(0xef, 0x44, 0x44) } else { theme::FAINT }; ui.painter().text(pos2(field_rect.right() - 16.0, field_rect.bottom() + 4.0), Align2::RIGHT_BOTTOM, text, FontId::new(10.0, FontFamily::Monospace), color);@@ -155,7 +155,8 @@ pub fn show(ui: &mut Ui, prompt: &mut String, refs: &References, state: &mut Pro fn editor_ui(ui: &mut Ui, prompt: &mut String, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, fixed_height: Option<f32>) -> editor::EditorOutput { let fullscreen = fixed_height.is_some();- let opts = EditorOptions { id: if fullscreen { props.id.with("focus-editor") } else { props.id.with("editor") }, placeholder: props.placeholder, enter_to_generate: props.enter_to_generate, mentions: props.mentions, max_length: if fullscreen { None } else { props.max_length }, fixed_height, fullscreen_button: !fullscreen, resizable: !fullscreen };+ let placeholder = crate::i18n::t(props.placeholder);+ let opts = EditorOptions { id: if fullscreen { props.id.with("focus-editor") } else { props.id.with("editor") }, placeholder: &placeholder, enter_to_generate: props.enter_to_generate, mentions: props.mentions, max_length: if fullscreen { None } else { props.max_length }, fixed_height, fullscreen_button: !fullscreen, resizable: !fullscreen }; let ctx = ui.ctx().clone(); editor::show(ui, prompt, &mut state.editor, &opts, &mut |_, key| match cache.get(&ctx, key) { crate::backend::media_cache::Lookup::Ready(t) => Some(t),@@ -183,7 +184,7 @@ fn generate_cluster(ui: &mut Ui, props: &PromptBoxProps<'_>, actions: &mut Vec<P let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 22.0, 36.0), Sense::hover()); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 7.0, rect.center().y), vec2(14.0, 14.0)), Icon::Coins, ink); ui.painter().galley(pos2(rect.left() + 20.0, rect.center().y - galley.size().y / 2.0), galley, ink);- resp.on_hover_text(format!("{credits} credit{} cost", if credits == 1 { "" } else { "s" }));+ resp.on_hover_text(crate::i18n::tf("{} credit{} cost", &[credits.to_string(), (if credits == 1 || crate::i18n::is_arabic() { "" } else { "s" }).to_string()])); } } @@ -208,7 +209,7 @@ fn confirm_clear(ctx: &egui::Context, id: Id, state: &mut PromptBoxState, action let resp = widgets::modal(ctx, id.with("confirm-clear"), 420.0, |ui| { ui.label(widgets::heading("Clear all?", 20.0)); ui.add_space(8.0);- ui.label(egui::RichText::new("This clears the prompt and also removes the attached references.").color(theme::MUTED));+ ui.label(egui::RichText::new(crate::i18n::t("This clears the prompt and also removes the attached references.")).color(theme::MUTED)); ui.add_space(16.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::button(ui, None, "Clear all", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() {@@ -240,6 +241,7 @@ fn expand_toggle(ui: &mut Ui, id: Id, rect: Rect, state: &mut PromptBoxState) { } fn warning_label(ui: &mut Ui, text: &str) {+ let text = crate::i18n::t(text); let pulse = (ui.input(|i| i.time) * 2.0).sin() as f32 * 0.25 + 0.75; ui.label(egui::RichText::new(text).size(12.0).color(theme::BAD)); let (rect, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());@@ -248,6 +250,7 @@ fn warning_label(ui: &mut Ui, text: &str) { } fn info_banner(ui: &mut Ui, text: &str) {+ let text = crate::i18n::t(text); egui::Frame::new().fill(theme::fade(theme::ACCENT, 0.12)).stroke(Stroke::new(1.0, theme::fade(theme::ACCENT, 0.35))).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { ui.horizontal(|ui| { icons::icon(ui, Icon::Info, 14.0, theme::ACCENT_INK);@@ -37,7 +37,7 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio None => widgets::pill(ui, Leading::Icon(icons::Icon::Sparkles), label, open), } });- let resp = resp.inner.on_hover_text("Model");+ let resp = resp.inner.on_hover_text(crate::i18n::t("Model")); let rows = Rows { id, selected, show_badges, providers }; let mut picked = None; let hovered_family_id = id.with("family");@@ -132,7 +132,7 @@ impl Rows<'_> { (true, true) => None, (false, true) => Some(m.description.clone()), (true, false) => Some(badges),- (false, false) => Some(format!("{} \u{b7} {badges}", m.description)),+ (false, false) => Some(crate::i18n::tf("{} \u{b7} {}", &[m.description.to_string(), badges.to_string()])), }; let allowed = Provider::for_model(&m.id); let Some(picks) = self.providers.filter(|_| allowed.len() > 1) else {@@ -50,6 +50,8 @@ pub enum Trigger<'a> { /// A picker whose popover lists `options`; returns the newly chosen value. pub fn option_picker<T: Clone + PartialEq>(ui: &mut Ui, id: Id, trigger: Trigger<'_>, title: &str, tooltip: &str, current: &T, options: &[PickOption<T>]) -> Option<T> {+ let title = crate::i18n::t(title);+ let tooltip = crate::i18n::t(tooltip); let selected = options.iter().find(|o| o.value == *current); let label = selected.map_or("", |o| o.label.as_str()); // Scoped to the parent, so the same picker in the box and in focus mode stay separate.@@ -102,7 +104,7 @@ pub fn popover_pill(ui: &mut Ui, id: Id, lead: Leading<'_>, label: &str, tooltip /// model's minimum and maximum. Returns the new value. pub fn duration_picker(ui: &mut Ui, id: Id, current: u32, min: u32, max: u32) -> Option<u32> { let mut value = current;- popover_pill(ui, id, Leading::Icon(Icon::Clock), &format!("{current}s"), "Duration", |ui| {+ popover_pill(ui, id, Leading::Icon(Icon::Clock), &crate::i18n::tf("{}s", &[current.to_string()]), "Duration", |ui| { ui.set_width(240.0); widgets::menu_header(ui, "Duration"); ui.add_space(4.0);@@ -113,12 +115,12 @@ pub fn duration_picker(ui: &mut Ui, id: Id, current: u32, min: u32, max: u32) -> }); ui.horizontal(|ui| { ui.add_space(8.0);- ui.label(egui::RichText::new(format!("{min}s")).size(11.0).color(theme::FAINT));+ ui.label(egui::RichText::new(crate::i18n::tf("{}s", &[min.to_string()])).size(11.0).color(theme::FAINT)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0);- ui.label(egui::RichText::new(format!("{max}s")).size(11.0).color(theme::FAINT));+ ui.label(egui::RichText::new(crate::i18n::tf("{}s", &[max.to_string()])).size(11.0).color(theme::FAINT)); ui.centered_and_justified(|ui| {- ui.label(egui::RichText::new(format!("{value}s")).size(13.0).family(theme::semibold()).color(theme::INK));+ ui.label(egui::RichText::new(crate::i18n::tf("{}s", &[value.to_string()])).size(13.0).family(theme::semibold()).color(theme::INK)); }); }); });@@ -154,7 +154,7 @@ pub fn titlebar(ui: &mut Ui, account: &Account<'_>, mark: &egui::TextureHandle) ui.painter().rect_filled(mark_rect, theme::RADIUS, theme::WASH); } ui.painter().image(mark.id(), Rect::from_center_size(mark_rect.center(), vec2(18.0, 16.0)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), theme::ACCENT);- if mark_resp.on_hover_text("Home").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {+ if mark_resp.on_hover_text(crate::i18n::t("Home")).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { action = Some(ShellAction::Navigate(Page::Home)); } if widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() {@@ -282,10 +282,10 @@ fn account_row(ui: &mut Ui, name: &str, credits: Option<u64>) -> egui::Response let initial = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default(); p.text(avatar.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), Color32::WHITE); p.text(pos2(avatar.right() + 10.0, rect.center().y - 8.0), Align2::LEFT_CENTER, name, FontId::new(13.5, theme::medium()), theme::INK);- let sub = credits.map_or_else(|| "Account".to_owned(), |c| format!("{} credits", group_thousands(c)));+ let sub = credits.map_or_else(|| "Account".to_owned(), |c| crate::i18n::tf("{} credits", &[group_thousands(c).to_string()])); p.text(pos2(avatar.right() + 10.0, rect.center().y + 9.0), Align2::LEFT_CENTER, sub, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED); icons::paint(p, Rect::from_center_size(pos2(rect.right() - 16.0, rect.center().y), vec2(14.0, 14.0)), Icon::Settings, theme::DIM);- resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text("Settings")+ resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(crate::i18n::t("Settings")) } /// The feed toggles a page's header shows.@@ -340,7 +340,7 @@ fn credits_pill(ui: &mut Ui, credits: u64) -> egui::Response { ui.painter().rect(rect, theme::RADIUS, if hovered { theme::WASH_HOVER } else { theme::WASH }, theme::hairline(), egui::StrokeKind::Inside); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(14.0, 14.0)), Icon::Coins, theme::INK); ui.painter().galley(pos2(rect.left() + 28.0, rect.center().y - galley.size().y / 2.0), galley, theme::INK);- resp.on_hover_text("Your credit balance \u{b7} Buy credits").on_hover_cursor(egui::CursorIcon::PointingHand)+ resp.on_hover_text(crate::i18n::t("Your credit balance \u{b7} Buy credits")).on_hover_cursor(egui::CursorIcon::PointingHand) } fn account_menu(ui: &mut Ui, account: &Account<'_>) -> Option<ShellAction> {@@ -189,5 +189,52 @@ fn install_fonts(ctx: &egui::Context) { stack.extend(fallback.iter().cloned()); fonts.families.insert(family, stack); }+ install_arabic_fallback(&mut fonts); ctx.set_fonts(fonts); }++/// (path, face index) candidates for an Arabic face. Appended after the Latin ones: the bundled+/// faces carry no Arabic glyphs, so without one العربية renders as tofu. The first face that+/// exists wins; on a system with none, the UI stays English (the language picker keeps working).+/// Only ever reads fonts the operating system already ships (nothing is redistributed).+const ARABIC: &[(&str, u32)] = &[+ // Windows: Segoe UI, then Tahoma and Traditional Arabic.+ ("C:\\Windows\\Fonts\\segoeui.ttf", 0),+ ("C:\\Windows\\Fonts\\tahoma.ttf", 0),+ ("C:\\Windows\\Fonts\\trado.ttf", 0),+ ("C:\\Windows\\Fonts\\arial.ttf", 0),+ // macOS: Geeza Pro.+ ("/System/Library/Fonts/GeezaPro.ttc", 0),+ ("/System/Library/Fonts/Supplemental/Arial Unicode.ttf", 0),+ // Linux: Noto (static and variable spellings), then Naskh, KacstOne, Amiri, DejaVu.+ ("/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", 0),+ ("/usr/share/fonts/truetype/noto/NotoSansArabic[wdth,wght].ttf", 0),+ ("/usr/share/fonts/noto/NotoSansArabic-Regular.ttf", 0),+ ("/usr/share/fonts/noto/NotoNaskhArabic-Regular.ttf", 0),+ ("/usr/share/fonts/opentype/noto/NotoSansArabic-Regular.ttf", 0),+ ("/usr/share/fonts/truetype/kacst/KacstOne.ttf", 0),+ ("/usr/share/fonts/truetype/amiri/Amiri-Regular.ttf", 0),+ ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 0),+ ("/usr/share/fonts/TTF/DejaVuSans.ttf", 0),+];++/// Appends the system's own Arabic face to every family the UI draws text with, so Arabic+/// strings render through any of them (body, medium, semibold, display and the mono HUD+/// labels and buttons).+fn install_arabic_fallback(fonts: &mut FontDefinitions) {+ for (path, index) in ARABIC {+ let Ok(bytes) = std::fs::read(path) else {+ continue;+ };+ let mut data = FontData::from_owned(bytes);+ data.index = *index;+ fonts.font_data.insert("system-arabic".into(), Arc::new(data));+ let families = [FontFamily::Proportional, FontFamily::Monospace, medium(), semibold(), display(), mono(), mono_bold()];+ for family in families {+ if let Some(stack) = fonts.families.get_mut(&family) {+ stack.push("system-arabic".into());+ }+ }+ return;+ }+}@@ -35,11 +35,11 @@ pub struct Toasts { impl Toasts { pub fn success(&mut self, message: impl Into<String>) {- self.pending.push((ToastKind::Success, message.into()));+ self.pending.push((ToastKind::Success, crate::i18n::t(&message.into()))); } pub fn error(&mut self, message: impl Into<String>) {- self.pending.push((ToastKind::Error, message.into()));+ self.pending.push((ToastKind::Error, crate::i18n::t(&message.into()))); } /// Draws the stack over everything else and drops expired toasts.@@ -92,7 +92,7 @@ pub fn format_time(secs: f32) -> String { fn volume(ui: &mut Ui, id: Id, rect: Rect, audio: &mut AudioPlayer) { let popup_id = id.with("volume-popup"); let open = Popup::is_id_open(ui.ctx(), popup_id);- let resp = ui.interact(rect, id.with("volume"), Sense::click()).on_hover_text("Volume").on_hover_cursor(egui::CursorIcon::PointingHand);+ let resp = ui.interact(rect, id.with("volume"), Sense::click()).on_hover_text(crate::i18n::t("Volume")).on_hover_cursor(egui::CursorIcon::PointingHand); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Volume")); if resp.hovered() || open { ui.painter().rect_filled(rect, theme::RADIUS, theme::WASH);@@ -16,16 +16,18 @@ pub fn caps(ui: &Ui, text: &str, size: f32, family: egui::FontFamily, spacing: f /// A `.hud-label`: 11 px Geist Mono, upper case, widely spaced. pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {- let galley = caps(ui, text, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, color);+ let text = crate::i18n::t(text);+ let galley = caps(ui, &text, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, color); let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover()); ui.painter().galley(rect.min, galley, color);- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &text)); resp } /// A sidebar section or menu heading: 11 px Geist Mono 600, upper case. pub fn section_label(ui: &mut Ui, text: &str, color: Color32) -> Response {- let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, color);+ let text = crate::i18n::t(text);+ let galley = caps(ui, &text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, color); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover()); let pos = pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0); ui.painter().galley(pos, galley, color);@@ -105,6 +107,7 @@ pub fn pill(ui: &mut Ui, leading: Leading<'_>, label: &str, open: bool) -> Respo /// (`text-base-fg/50`, `hover:bg-white/5`): the count picker and clear-all. pub fn ghost(ui: &mut Ui, icon: Icon, label: Option<&str>, tip: &str, active: bool) -> Response { let enabled = ui.is_enabled();+ let tip = crate::i18n::t(tip); let galley = label.map(|l| ui.painter().layout_no_wrap(l.to_owned(), FontId::new(14.0, theme::medium()), theme::INK)); let w = match &galley { Some(g) => 10.0 + 15.0 + 6.0 + g.size().x + 10.0,@@ -129,8 +132,8 @@ pub fn ghost(ui: &mut Ui, icon: Icon, label: Option<&str>, tip: &str, active: bo }, None => icons::paint(p, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, ink), }- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));- let resp = resp.on_hover_text(tip);+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &tip));+ let resp = resp.on_hover_text(&tip); if enabled { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else {@@ -140,6 +143,7 @@ pub fn ghost(ui: &mut Ui, icon: Icon, label: Option<&str>, tip: &str, active: bo /// A small square icon button for overlays (`h-7 w-7`, `hover:bg-white/15`). pub fn overlay_icon(ui: &mut Ui, icon: Icon, tip: &str) -> Response {+ let tip = crate::i18n::t(tip); let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); let hovered = resp.hovered(); if hovered {@@ -147,12 +151,13 @@ pub fn overlay_icon(ui: &mut Ui, icon: Icon, tip: &str) -> Response { } let ink = if hovered { Color32::WHITE } else { Color32::from_white_alpha(217) }; icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(14.0, 14.0)), icon, ink);- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand) } /// The round blue generate button (`GenerateIconButton`): an up arrow, a spinner while `loading`. pub fn generate_button(ui: &mut Ui, enabled: bool, loading: bool, tip: &str) -> Response {+ let tip = crate::i18n::t(tip); let (rect, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), if enabled { Sense::click() } else { Sense::hover() }); let hovered = resp.hovered() && enabled; let fill = match (enabled, hovered) {@@ -168,7 +173,7 @@ pub fn generate_button(ui: &mut Ui, enabled: bool, loading: bool, tip: &str) -> } else { icons::paint(ui.painter(), inner, Icon::ArrowUp, ink); }- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, "Generate"));+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, crate::i18n::t("Generate"))); let resp = resp.on_hover_text(tip); if enabled { resp.on_hover_cursor(egui::CursorIcon::PointingHand)@@ -194,7 +199,8 @@ pub enum Kind { /// wide and `height` tall. pub fn button(ui: &mut Ui, icon: Option<Icon>, text: &str, kind: Kind, width: f32, height: f32) -> Response { let enabled = ui.is_enabled();- let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, theme::INK);+ let text = crate::i18n::t(text);+ let galley = caps(ui, &text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, theme::INK); let icon_w = if icon.is_some() { 13.0 + 8.0 } else { 0.0 }; let size = vec2((galley.size().x + icon_w + 40.0).max(width), height); let (rect, resp) = ui.allocate_exact_size(size, Sense::click());@@ -332,7 +338,7 @@ pub fn progress_bar(painter: &Painter, rect: Rect, fraction: f32) { /// A heading in the display face (Archivo 620, tracked in like the site's `tracking-[-0.03em]`). pub fn heading(text: &str, size: f32) -> egui::RichText {- egui::RichText::new(text).size(size).family(theme::display()).color(theme::INK).extra_letter_spacing(-0.03 * size)+ egui::RichText::new(crate::i18n::t(text)).size(size).family(theme::display()).color(theme::INK).extra_letter_spacing(-0.03 * size) } /// A square modal on the panel surface with a dimmed backdrop. Returns whether it asked to close@@ -348,6 +354,7 @@ pub fn modal(ctx: &egui::Context, id: egui::Id, width: f32, content: impl FnOnce /// A mono link with an ↗: muted, brighter on hover. Opens `url` in the browser. pub fn hud_link(ui: &mut Ui, text: &str, url: &str) -> Response {+ let text = crate::i18n::t(text); let galley = caps(ui, &format!("{text} \u{2197}"), theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::MUTED); let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click()); let color = if resp.hovered() { theme::INK_STRONG } else { theme::MUTED };