collective

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

feat(ui-egui): add RTL layout direction, BiDi text layout, and Hebrew font fallback support

feat(ui-egui): add RTL layout direction, BiDi text layout, and Hebrew font fallback support ## Summary Adds RTL layout direction support, BiDi (`TextDirection`) handling in custom layout jobs, and Hebrew (`Script::Hebrew`) system/user font fallback discovery in `artcraft-launcher-ui-egui`: - **`crates/ui-egui/src/i18n/lang.rs`**: - Adds `Script::Hebrew` to `Script` and `Script::ALL`. - Adds `rtl: bool` to `LangInfo` and `Lang::rtl(self) -> bool`. - Adds `plural_hebrew(n: u64) -> usize` (CLDR `he` integer plural rule: `1 => 0` (`one`), `2 => 1` (`two`), `_ => 2` (`other`)). - Normalizes legacy ISO 639-1 `"iw"` locale tags to `"he"` in `candidates()`. - **`crates/ui-egui/src/c

Status
open
Score
0.00
Adopters
0 other operators
Author
hrb9 (imported from pull request #890, open)
Base
7aafad661a11594a70ae949471d481f30d8fe64a
Size
+346 −71 in 10 files
Review
pending
Shared
2026-10-28
Id
71507b992118cf5fdf235aa3

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/app.rs crates/ui-egui/src/card_parts.rs crates/ui-egui/src/cjk_font.rs crates/ui-egui/src/i18n/lang.rs crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/settings_dialog.rs crates/ui-egui/src/sheet.rs crates/ui-egui/src/theme.rs crates/ui-egui/src/views.rs crates/ui-egui/src/widgets.rs

diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -754,6 +754,10 @@ impl LauncherUi {         // Set the current language only after the font gate: a face installed by `set_fonts`         // becomes active next pass, so drawing with it in the pass that installed it would panic.         set_current(drawable_lang(&self.settings.language, self.cjk_font));+        let rtl = crate::i18n::current().rtl();+        if ctx.global_style().right_to_left != rtl {+            ctx.all_styles_mut(|style| style.right_to_left = rtl);+        }         self.zoom_input(&ctx);         if self.applied_theme != Some(self.settings.theme) {             theme::apply(&ctx, self.settings.theme);diff --git a/crates/ui-egui/src/card_parts.rs b/crates/ui-egui/src/card_parts.rs--- a/crates/ui-egui/src/card_parts.rs+++ b/crates/ui-egui/src/card_parts.rs@@ -31,6 +31,9 @@ pub(crate) fn last_updated(e: &AppEntry) -> Option<String> { /// `text` in at most `rows` lines, ending in "…" when it is cut. pub(crate) fn blurb(ui: &mut Ui, text: &str, color: Color32, rows: usize) {     let mut job = egui::text::LayoutJob::simple(text.to_string(), FontId::proportional(13.0), color, ui.available_width());+    if crate::i18n::current().rtl() {+        job.direction = egui::epaint::text::TextDirection::RightToLeft;+    }     job.wrap.max_rows = rows;     job.wrap.break_anywhere = false;     job.wrap.overflow_character = Some('…');diff --git a/crates/ui-egui/src/cjk_font.rs b/crates/ui-egui/src/cjk_font.rs--- a/crates/ui-egui/src/cjk_font.rs+++ b/crates/ui-egui/src/cjk_font.rs@@ -40,6 +40,7 @@ fn script_name(script: Script) -> &'static str {     match script {         Script::Chinese => "Chinese",         Script::Japanese => "Japanese",+        Script::Hebrew => "Hebrew",     } } @@ -48,6 +49,9 @@ fn script_families(script: Script) -> &'static str {     match script {         Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC, WenQuanYi and MiSans",         Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic",+        Script::Hebrew => {+            "SF Hebrew, Arial Hebrew, Arial, Segoe UI, Noto Sans Hebrew, Heebo, Rubik, Assistant, David Libre, FreeSans and DejaVu Sans"+        }     } } @@ -84,6 +88,7 @@ pub(crate) fn load(script: Script) -> Option<FontData> {     None } +/// Reads a font file up to [`MAX_FONT_BYTES`], returning `None` if missing, not a regular file, or oversized. fn read(path: &Path) -> Option<Vec<u8>> {     let meta = std::fs::metadata(path).ok()?;     if !meta.is_file() {@@ -123,6 +128,7 @@ fn candidates(script: Script) -> Vec<Candidate> {     match script {         Script::Chinese => chinese_candidates(),         Script::Japanese => japanese_candidates(),+        Script::Hebrew => hebrew_candidates(),     } } @@ -305,6 +311,76 @@ fn japanese_candidates() -> Vec<Candidate> {     out } +/// The free OFL Hebrew faces a user may install by hand, by family preference.+const HEBREW_USER_FILES: [(&str, &str); 6] = [+    ("NotoSansHebrew-Regular.ttf", "Noto Sans Hebrew"),+    ("Rubik-Regular.ttf", "Rubik"),+    ("Heebo-Regular.ttf", "Heebo"),+    ("Assistant-Regular.ttf", "Assistant"),+    ("DavidLibre-Regular.ttf", "David Libre"),+    ("Alef-Regular.ttf", "Alef"),+];++/// Where each system keeps a Hebrew font, best first.+fn hebrew_candidates() -> Vec<Candidate> {+    let mut out = Vec::new();+    let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });+    #[cfg(target_os = "macos")]+    {+        add("/System/Library/Fonts/SFHebrew.ttf".into(), "SF Hebrew");+        for path in mac_font_assets("SFHebrew.ttf") {+            add(path, "SF Hebrew");+        }+        add("/System/Library/Fonts/ArialHB.ttc".into(), "Arial Hebrew");+        add("/System/Library/Fonts/Supplemental/Arial Unicode.ttf".into(), "Arial Unicode MS");+        add("/System/Library/Fonts/Supplemental/Arial.ttf".into(), "Arial");+    }+    #[cfg(target_os = "windows")]+    {+        let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);+        add(root.join("Fonts").join("segoeui.ttf"), "Segoe UI");+        add(root.join("Fonts").join("arial.ttf"), "Arial");+        add(root.join("Fonts").join("david.ttf"), "David");+    }+    #[cfg(all(unix, not(target_os = "macos")))]+    {+        const NOTO_DIRS: [&str; 8] = [+            "/usr/share/fonts/truetype/noto",+            "/usr/share/fonts/opentype/noto",+            "/usr/share/fonts/noto",+            "/usr/share/fonts/google-noto-vf",+            "/usr/share/fonts/google-noto-sans-hebrew-vf-fonts",+            "/usr/share/fonts/google-noto-sans-hebrew-fonts",+            "/usr/local/share/fonts/noto",+            "/usr/local/share/fonts",+        ];+        for file in ["NotoSansHebrew-Regular.ttf", "NotoSansHebrew-Regular.otf", "NotoSansHebrew[wdth,wght].ttf"] {+            for dir in NOTO_DIRS {+                add(Path::new(dir).join(file), "Noto Sans Hebrew");+            }+        }+        const FALLBACK_DIRS: [&str; 6] = [+            "/usr/share/fonts/truetype/freefont",+            "/usr/share/fonts/gnu-free",+            "/usr/share/fonts/truetype/dejavu",+            "/usr/share/fonts/TTF",+            "/usr/share/fonts/dejavu-sans-fonts",+            "/usr/local/share/fonts",+        ];+        for (file, family) in [("FreeSans.ttf", "FreeSans"), ("DejaVuSans.ttf", "DejaVu Sans")] {+            for dir in FALLBACK_DIRS {+                add(Path::new(dir).join(file), family);+            }+        }+    }+    for (file, family) in HEBREW_USER_FILES {+        for dir in user_font_dirs() {+            add(dir.join(file), family);+        }+    }+    out+}+ /// `file` in macOS's downloaded font assets: /// `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/*.asset/AssetData/<file>`. #[cfg(target_os = "macos")]@@ -394,6 +470,18 @@ mod tests {         assert!(first_misans > 0, "the system's own fonts come first");     } +    /// System Hebrew fonts come before user-installed OFL Hebrew faces, and every path is absolute.+    #[test]+    fn hebrew_candidates_include_system_and_user_fonts() {+        let cands = hebrew_candidates();+        let names: Vec<String> = cands.iter().filter_map(|c| c.path.file_name().map(|n| n.to_string_lossy().into_owned())).collect();+        assert!(names.iter().any(|n| n == "NotoSansHebrew-Regular.ttf"), "{names:?}");+        assert!(names.iter().any(|n| n == "Rubik-Regular.ttf"), "{names:?}");+        assert!(cands.iter().all(|c| c.path.is_absolute() || c.path.starts_with("/")));+        let first_rubik = cands.iter().position(|c| c.family == "Rubik").unwrap();+        assert!(first_rubik > 0, "the system's own Hebrew fonts come first");+    }+     #[test]     fn face_index_finds_families_in_real_fonts() {         // The bundled Latin faces stand in for system fonts the test machine may not have.diff --git a/crates/ui-egui/src/i18n/lang.rs b/crates/ui-egui/src/i18n/lang.rs--- a/crates/ui-egui/src/i18n/lang.rs+++ b/crates/ui-egui/src/i18n/lang.rs@@ -13,30 +13,33 @@ use super::detect::system_lang; pub enum Script {     Chinese,     Japanese,+    Hebrew, }  impl Script {     /// Every script a language can be written in.-    pub const ALL: [Script; 2] = [Script::Chinese, Script::Japanese];+    pub const ALL: [Script; 3] = [Script::Chinese, Script::Japanese, Script::Hebrew]; }  /// One supported UI language. pub struct LangInfo {-    /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`).+    /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`, `he`).     /// Also the `interfaceLanguage` value.     pub code: &'static str,     /// The language's name in itself, shown in the Preferences dropdown.     pub name: &'static str,     /// The language's name in English, shown instead where its own script can't be drawn.     pub english_name: &'static str,-    /// The writing system this language is drawn in, or `None` if it needs no system CJK font.+    /// The writing system this language is drawn in, or `None` if it needs no system font.     pub script: Option<Script>,+    /// Whether this language is written right-to-left (Hebrew, Arabic).+    pub rtl: bool,     /// Catalog file contents (empty for the built-in English).     pub source: &'static str,     /// Plural form index for a count (English, German, Spanish and Hungarian: 0 = one, 1 = other;     /// Russian: 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only-    /// for 1, 1 = few, 2 = many; French: 0 = one for 0 and 1; a language whose nouns don't inflect-    /// for number: always 0).+    /// for 1, 1 = few, 2 = many; Hebrew: 0 = one, 1 = two, 2 = other; French: 0 = one for 0 and 1;+    /// a language whose nouns don't inflect for number: always 0).     pub plural: fn(u64) -> usize,     /// Number of plural forms the catalog's `@plural` rows carry (checked by tests).     pub plural_forms: usize,@@ -93,13 +96,23 @@ pub(super) fn plural_polish(n: u64) -> usize {     } } +/// Hebrew integer counts (CLDR 44+): 1 is `one` (0), 2 is `two` (1), everything else is `other` (2).+pub(super) fn plural_hebrew(n: u64) -> usize {+    match n {+        1 => 0,+        2 => 1,+        _ => 2,+    }+}+ /// The registry. English first: it is the fallback and the source language.-pub static LANGUAGES: [LangInfo; 11] = [+pub static LANGUAGES: [LangInfo; 12] = [     LangInfo {         code: "en",         name: "English",         english_name: "English",         script: None,+        rtl: false,         source: "",         plural: plural_one_other,         plural_forms: 2,@@ -111,6 +124,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "Русский",         english_name: "Russian",         script: None,+        rtl: false,         source: include_str!("ru.tsv"),         plural: plural_east_slavic,         plural_forms: 3,@@ -122,6 +136,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "简体中文",         english_name: "Simplified Chinese",         script: Some(Script::Chinese),+        rtl: false,         source: include_str!("zh-CN.tsv"),         plural: plural_none,         plural_forms: 1,@@ -133,6 +148,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "Deutsch",         english_name: "German",         script: None,+        rtl: false,         source: include_str!("de.tsv"),         plural: plural_one_other,         plural_forms: 2,@@ -144,6 +160,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "Français",         english_name: "French",         script: None,+        rtl: false,         source: include_str!("fr.tsv"),         plural: plural_zero_one,         plural_forms: 2,@@ -155,6 +172,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "Português (Brasil)",         english_name: "Portuguese (Brazil)",         script: None,+        rtl: false,         source: include_str!("pt-BR.tsv"),         plural: plural_one_other,         plural_forms: 2,@@ -166,6 +184,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "Español",         english_name: "Spanish",         script: None,+        rtl: false,         source: include_str!("es.tsv"),         plural: plural_one_other,         plural_forms: 2,@@ -177,6 +196,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "Čeština",         english_name: "Czech",         script: None,+        rtl: false,         source: include_str!("cs.tsv"),         plural: plural_czech,         plural_forms: 3,@@ -188,6 +208,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "Polski",         english_name: "Polish",         script: None,+        rtl: false,         source: include_str!("pl.tsv"),         plural: plural_polish,         plural_forms: 3,@@ -199,6 +220,7 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "Magyar",         english_name: "Hungarian",         script: None,+        rtl: false,         source: include_str!("hu.tsv"),         plural: plural_one_other,         plural_forms: 2,@@ -210,15 +232,29 @@ pub static LANGUAGES: [LangInfo; 11] = [         name: "日本語",         english_name: "Japanese",         script: Some(Script::Japanese),+        rtl: false,         source: include_str!("ja.tsv"),         plural: plural_none,         plural_forms: 1,         complete_menus: true,         catalog: OnceLock::new(),     },+    LangInfo {+        code: "he",+        name: "עברית",+        english_name: "Hebrew",+        script: Some(Script::Hebrew),+        rtl: true,+        source: "",+        plural: plural_hebrew,+        plural_forms: 3,+        complete_menus: false,+        catalog: OnceLock::new(),+    }, ];  impl LangInfo {+    /// Lazily parses and returns this language's translation catalog.     pub(super) fn catalog(&self) -> &Catalog {         self.catalog.get_or_init(|| Catalog::parse(self.source))     }@@ -254,7 +290,9 @@ impl Lang {     pub const PL: Lang = Lang(&LANGUAGES[8]);     pub const HU: Lang = Lang(&LANGUAGES[9]);     pub const JA: Lang = Lang(&LANGUAGES[10]);+    pub const HE: Lang = Lang(&LANGUAGES[11]); +    /// Canonical BCP 47 language code (`en`, `he`, `zh-CN`, …).     pub fn code(self) -> &'static str {         self.0.code     }@@ -286,6 +324,7 @@ impl Lang {         std::iter::once(Lang::EN).chain(rest).collect()     } +    /// The language's autonym (native display name).     pub fn name(self) -> &'static str {         self.0.name     }@@ -300,11 +339,16 @@ impl Lang {         self.0.script.is_some()     } -    /// The writing system this language is drawn in, if it needs a system CJK font.+    /// The writing system this language is drawn in, if it needs a system font.     pub fn script(self) -> Option<Script> {         self.0.script     } +    /// Is this language written right-to-left?+    pub fn rtl(self) -> bool {+        self.0.rtl+    }+     /// Does this language's catalog claim to cover every `tl!` literal?     pub fn complete_menus(self) -> bool {         self.0.complete_menus@@ -315,6 +359,7 @@ impl Lang {         LANGUAGES.iter().position(|l| l.code == self.0.code).unwrap_or(0)     } +    /// Returns the parsed translation catalog for this language.     pub(super) fn catalog(self) -> &'static Catalog {         self.0.catalog()     }@@ -337,7 +382,11 @@ pub(super) fn candidates(tag: &str) -> Vec<String> {         return Vec::new();     }     let base = head.replace('_', "-").to_ascii_lowercase();-    let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).take(MAX_SUBTAGS).collect();+    let mut parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).take(MAX_SUBTAGS).collect();+    // Normalize the deprecated ISO 639-1 code for Hebrew (`iw` -> `he`).+    if let Some(first @ &mut "iw") = parts.first_mut() {+        *first = "he";+    }     (1..=parts.len()).rev().filter_map(|n| parts.get(..n)).map(|p| p.join("-")).collect() } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs--- a/crates/ui-egui/src/i18n/tests.rs+++ b/crates/ui-egui/src/i18n/tests.rs@@ -4,7 +4,7 @@ use std::collections::{BTreeSet, HashSet};  use super::catalog::{Catalog, unescape};-use super::lang::{name_sort_key, plural_czech, plural_east_slavic, plural_polish};+use super::lang::{candidates, name_sort_key, plural_czech, plural_east_slavic, plural_hebrew, plural_polish}; use super::{LANGUAGES, Lang, LangInfo, lang_from_tag, message, tr, trn};  /// Each catalog's rows as (line number, context, source, translation), split exactly as the@@ -225,6 +225,7 @@ fn czech_plural_rule_works() {     assert_eq!(trn(Lang::CS, 5, "{n} app", "{n} apps"), "5 aplikací"); } +/// Polish plural forms and English fallback for untranslated plural keys. #[test] fn polish_plural_rule_works() {     // Polish says "one" only for 1 itself: 21, 31 and 101 take "many", unlike Russian.@@ -244,6 +245,24 @@ fn polish_plural_rule_works() {     assert_eq!(trn(Lang::PL, 3, "{n} unknown item", "{n} unknown items"), "3 unknown items"); } +/// CLDR 44+ Hebrew plural indices (one/two/other), legacy `iw` locale tag normalization, and `Lang::HE` resolution.+#[test]+fn hebrew_plural_rule_and_legacy_tag_candidates_work() {+    assert_eq!(plural_hebrew(1), 0);+    assert_eq!(plural_hebrew(2), 1);+    for n in [0, 3, 4, 10, 11, 20, 21, 100] {+        assert_eq!(plural_hebrew(n), 2, "{n}");+    }+    assert_eq!(candidates("iw_IL.UTF-8"), vec!["he-il", "he"]);+    assert_eq!(candidates("iw"), vec!["he"]);+    for tag in ["he", "he-IL", "he_IL.UTF-8", "iw", "iw-IL", "iw_IL.UTF-8", "HE_il"] {+        assert_eq!(lang_from_tag(tag), Some(Lang::HE), "{tag}");+    }+    assert_eq!(Lang::from_code("he"), Some(Lang::HE));+    assert!(Lang::HE.rtl());+}++/// Untranslated plural keys fall back to the English singular/plural form by count. #[test] fn untranslated_plurals_fall_back_to_english_by_count() {     // Russian's rule says "one" for 21, but English says "other".diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs--- a/crates/ui-egui/src/settings_dialog.rs+++ b/crates/ui-egui/src/settings_dialog.rs@@ -17,21 +17,24 @@ use crate::widgets::{self, Cell, Kind}; /// accessibility (0.37's kittest fails unnamed input widgets). fn row(ui: &mut Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut Ui, egui::Id)) {     let t = Tokens::get(ui.ctx());+    let rtl = crate::i18n::current().rtl();     let font = egui::TextStyle::Body.resolve(ui.style());     let label_width = ui.painter().layout_no_wrap(label.to_string(), font, t.ink).size().x;     if label_width + control_width + ui.spacing().item_spacing.x > ui.available_width() {         let label = ui.label(label);         add(ui, label.id);     } else {-        ui.horizontal(|ui| {+        let outer = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };+        let inner = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };+        ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), outer, |ui| {             let label = ui.label(label);-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+            ui.with_layout(inner, |ui| {                 // A ComboBox grows to the width its layout offers, so leaving it loose made it                 // start at the label's last glyph and paint over the words at a narrow dialog-                // width. A fixed-width allocation at the right keeps it clear of the label and-                // still leaves the dialog itself as narrow as before.+                // width. A fixed-width allocation at the opposite edge keeps it clear of the label+                // and still leaves the dialog itself as narrow as before.                 let width = control_width.min(ui.available_width());-                ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {+                ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), outer, |ui| {                     add(ui, label.id);                 });             });@@ -39,12 +42,14 @@ fn row(ui: &mut Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut Ui     } } +/// Draws a secondary muted caption line inside a settings panel. fn note(ui: &mut Ui, text: &str) {     let t = Tokens::get(ui.ctx());     ui.label(RichText::new(text).size(12.0).color(t.muted)); }  impl LauncherUi {+    /// Renders the Settings modal dialog and returns `true` when the user closes it.     pub(crate) fn settings_dialog(&mut self, ui: &mut Ui, host: &mut dyn Host) -> bool {         let t = Tokens::get(ui.ctx());         let size = sheet::size(ui.ctx(), vec2(1000.0, 820.0));@@ -86,13 +91,21 @@ impl LauncherUi {             };             if stack_actions {                 quit(ui, host);-                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+                let end = if crate::i18n::current().rtl() {+                    egui::Layout::left_to_right(egui::Align::Center)+                } else {+                    egui::Layout::right_to_left(egui::Align::Center)+                };+                ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), end, |ui| {                     close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();                 });             } else {-                ui.horizontal(|ui| {+                let rtl = crate::i18n::current().rtl();+                let outer = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };+                let inner = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };+                ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), outer, |ui| {                     quit(ui, host);-                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+                    ui.with_layout(inner, |ui| {                         close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();                     });                 });@@ -158,8 +171,14 @@ impl LauncherUi {         let pref = crate::i18n::Lang::from_pref(&self.settings.language);         if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {             // In English on purpose: the language asked for can't be drawn.-            let text = "No font for this language was found on this system, so the interface stays in English. \-                        Installing one (for example Noto Sans CJK) and restarting fixes it.";+            let font_example = match pref.script() {+                Some(crate::i18n::Script::Hebrew) => "Noto Sans Hebrew",+                _ => "Noto Sans CJK",+            };+            let text = format!(+                "No font for this language was found on this system, so the interface stays in English. \+                 Installing one (for example {font_example}) and restarting fixes it."+            );             ui.add(egui::Label::new(RichText::new(text).size(12.0).color(t.warn)).wrap());             ui.add_space(14.0);         }diff --git a/crates/ui-egui/src/sheet.rs b/crates/ui-egui/src/sheet.rs--- a/crates/ui-egui/src/sheet.rs+++ b/crates/ui-egui/src/sheet.rs@@ -27,23 +27,28 @@ pub(crate) fn frame(t: &Tokens) -> Frame { pub(crate) fn hero(ui: &mut Ui, icon: Option<ImageSource<'static>>, title: &str, caption: &str, right: impl FnOnce(&mut Ui)) -> f32 {     let t = Tokens::get(ui.ctx());     let top = ui.cursor().top();+    let rtl = crate::i18n::current().rtl();+    let align = if rtl { egui::Align::Max } else { egui::Align::Min };+    let row_layout = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };+    let end_layout = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };     let shown =         Frame::new().fill(tint(t.accent, if t.dark { 0.09 } else { 0.07 })).inner_margin(Margin::symmetric(theme::PAD as i8, 18)).show(ui, |ui| {             ui.set_width(ui.available_width());-            ui.horizontal(|ui| {+            let row_h = if icon.is_some() { 44.0 } else { ui.spacing().interact_size.y };+            ui.allocate_ui_with_layout(vec2(ui.available_width(), row_h), row_layout, |ui| {                 if let Some(icon) = icon {                     // Decorative: the heading beside it carries the title.                     ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(44.0, 44.0)));                     ui.add_space(6.0);                 }-                ui.vertical(|ui| {+                ui.with_layout(egui::Layout::top_down(align), |ui| {                     ui.spacing_mut().item_spacing.y = 4.0;                     ui.label(widgets::heading(title, theme::SHEET_TITLE, t.ink_strong));                     if !caption.is_empty() {                         widgets::hud(ui, caption, t.faint);                     }                 });-                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);+                ui.with_layout(end_layout, right);             });         });     let r = shown.response.rect;@@ -70,46 +75,81 @@ pub(crate) fn accent_scroll(ui: &mut Ui, t: &Tokens, bar: Color32) -> egui::Visu  /// Two columns of equal width a `gap` apart when `wide`; otherwise `left` then `right`, stacked. pub(crate) fn columns(ui: &mut Ui, wide: bool, gap: f32, left: impl FnOnce(&mut Ui), right: impl FnOnce(&mut Ui)) {+    let rtl = crate::i18n::current().rtl();+    let align = if rtl { egui::Align::Max } else { egui::Align::Min };     if !wide {-        left(ui);-        right(ui);+        ui.with_layout(egui::Layout::top_down(align), |ui| {+            left(ui);+            right(ui);+        });         return;     }     let w = ((ui.available_width() - gap) / 2.0).floor();     ui.horizontal_top(|ui| {         ui.spacing_mut().item_spacing.x = gap;-        ui.vertical(|ui| {-            ui.set_width(w);-            left(ui);-        });-        ui.vertical(|ui| {-            ui.set_width(w);-            right(ui);-        });+        if rtl {+            ui.with_layout(egui::Layout::top_down(align), |ui| {+                ui.set_width(w);+                right(ui);+            });+            ui.with_layout(egui::Layout::top_down(align), |ui| {+                ui.set_width(w);+                left(ui);+            });+        } else {+            ui.vertical(|ui| {+                ui.set_width(w);+                left(ui);+            });+            ui.vertical(|ui| {+                ui.set_width(w);+                right(ui);+            });+        }     }); }  /// A titled box: a hairline frame with a small caps heading, for one group of settings. pub(crate) fn panel(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) {     let t = Tokens::get(ui.ctx());+    let rtl = crate::i18n::current().rtl();     Frame::new().stroke(t.hairline()).inner_margin(theme::BOX_MARGIN).show(ui, |ui| {         ui.set_width(ui.available_width());-        widgets::hud(ui, title, t.faint);-        ui.add_space(8.0);-        body(ui);+        if rtl {+            ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {+                ui.set_width(ui.available_width());+                widgets::hud(ui, title, t.faint);+                ui.add_space(8.0);+                body(ui);+            });+        } else {+            widgets::hud(ui, title, t.faint);+            ui.add_space(8.0);+            body(ui);+        }     });     ui.add_space(14.0); } -/// [`panel`], but the body also receives the heading's id so its control can be named by that+/// [`panel`], passing the heading's accessibility node id to `body` so a lone control can name the /// visible heading (egui 0.37's `Response::labelled_by`), without adding a second identical label. pub(crate) fn panel_with_heading(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui, egui::Id)) {     let t = Tokens::get(ui.ctx());+    let rtl = crate::i18n::current().rtl();     Frame::new().stroke(t.hairline()).inner_margin(theme::BOX_MARGIN).show(ui, |ui| {         ui.set_width(ui.available_width());-        let heading = widgets::hud(ui, title, t.faint);-        ui.add_space(8.0);-        body(ui, heading.id);+        if rtl {+            ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {+                ui.set_width(ui.available_width());+                let heading = widgets::hud(ui, title, t.faint);+                ui.add_space(8.0);+                body(ui, heading.id);+            });+        } else {+            let heading = widgets::hud(ui, title, t.faint);+            ui.add_space(8.0);+            body(ui, heading.id);+        }     });     ui.add_space(14.0); }diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -212,6 +212,7 @@ pub fn apply(ctx: &egui::Context, choice: ThemeChoice) {         ThemeChoice::Light => egui::ThemePreference::Light,     });     ctx.all_styles_mut(|style| {+        style.right_to_left = crate::i18n::current().rtl();         style.spacing.item_spacing = egui::vec2(8.0, 6.0);         // Between a checkbox (or radio button) and its label.         style.spacing.icon_spacing = 10.0;diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs--- a/crates/ui-egui/src/views.rs+++ b/crates/ui-egui/src/views.rs@@ -67,34 +67,58 @@ impl LauncherUi {     pub(crate) fn header(&mut self, ui: &mut Ui, bar: TitleBar) {         let t = Tokens::get(ui.ctx());         let h = HEADER_H;+        let rtl = current().rtl();         if bar != TitleBar::Native {             widgets::title_bar_drag(ui);         }-        ui.horizontal(|ui| {+        ui.allocate_ui_with_layout(vec2(ui.available_width(), h), egui::Layout::left_to_right(egui::Align::Center), |ui| {             ui.spacing_mut().item_spacing.x = 0.0;             if bar == TitleBar::MacOs {                 ui.add_space(TRAFFIC_LIGHTS_W);             }-            // The ArtCraft wordmark exactly as supplied, in its own cell.-            let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);-            let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());-            egui::Image::new(crate::icons::artcraft_logo(t.dark)).alt_text(tl!("ArtCraft")).paint_at(ui, Rect::from_center_size(cell.center(), logo));-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {-                if bar == TitleBar::Custom {-                    widgets::window_buttons(ui, h);-                }+            if rtl {                 if widgets::nav_icon(ui, widgets::Glyph::Settings, tl!("Settings"), h, false).clicked() {                     self.dialog = Some(Dialog::Settings);                 }-                // The controls reserve their actual widths before the secondary text gets space.-                ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {+                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+                    if bar == TitleBar::Custom {+                        widgets::window_buttons(ui, h);+                    }+                    let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);+                    let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());+                    egui::Image::new(crate::icons::artcraft_logo(t.dark))+                        .alt_text(tl!("ArtCraft"))+                        .paint_at(ui, Rect::from_center_size(cell.center(), logo));                     if ui.available_width() >= 17.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {                         widgets::rule(ui, h);                         ui.add_space(16.0);                         widgets::hud(ui, tl!("Craft App Suite"), t.muted);                     }                 });-            });+            } else {+                // The ArtCraft wordmark exactly as supplied, in its own cell.+                let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);+                let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());+                egui::Image::new(crate::icons::artcraft_logo(t.dark))+                    .alt_text(tl!("ArtCraft"))+                    .paint_at(ui, Rect::from_center_size(cell.center(), logo));+                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+                    if bar == TitleBar::Custom {+                        widgets::window_buttons(ui, h);+                    }+                    if widgets::nav_icon(ui, widgets::Glyph::Settings, tl!("Settings"), h, false).clicked() {+                        self.dialog = Some(Dialog::Settings);+                    }+                    // The controls reserve their actual widths before the secondary text gets space.+                    ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {+                        if ui.available_width() >= 17.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {+                            widgets::rule(ui, h);+                            ui.add_space(16.0);+                            widgets::hud(ui, tl!("Craft App Suite"), t.muted);+                        }+                    });+                });+            }         });     } @@ -191,7 +215,10 @@ impl LauncherUi {     /// whether the window is narrow enough that a second row follows.     fn toolbar_row(&mut self, ui: &mut Ui, t: &Tokens, updates: usize) -> bool {         let mut compact = false;-        ui.horizontal(|ui| {+        let rtl = current().rtl();+        let outer = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };+        let inner = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };+        ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), outer, |ui| {             self.refresh_button(ui);             // Hiding an app is only undone from here, so the way back to the hidden ones stays             // while there are any: a toggle, lit while the hidden apps are what the page shows.@@ -206,8 +233,8 @@ impl LauncherUi {                 }             }             compact = ui.available_width() < theme::TOOLBAR_COMPACT_BELOW;-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {-                // Right to left: the view switch at the far right, then the search.+            ui.with_layout(inner, |ui| {+                // Opposite side: the view switch at the far edge, then the search.                 crate::bar::view_toggle(ui, &mut self.settings.view, [tl!("Show apps as cards"), tl!("Show apps as a table")]);                 ui.add_space(4.0);                 ui.add(@@ -215,7 +242,7 @@ impl LauncherUi {                         .hint_text(tl!("Search apps"))                         .desired_width(200.0)                         .min_size(vec2(0.0, theme::CONTROL_H))-                        .align(egui::Align2::LEFT_CENTER)+                        .align(if rtl { egui::Align2::RIGHT_CENTER } else { egui::Align2::LEFT_CENTER })                         .margin(vec2(10.0, 0.0)),                 )                 .accessible_name(tl!("Search apps"));@@ -237,14 +264,17 @@ impl LauncherUi {      /// The toolbar's second row on narrow windows: the sort picker, then update-all.     fn toolbar_compact_row(&mut self, ui: &mut Ui, t: &Tokens, updates: usize) {+        let rtl = current().rtl();+        let outer = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };+        let inner = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };         ui.add_space(6.0);-        ui.horizontal(|ui| {+        ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), outer, |ui| {             if self.settings.view == ViewMode::Grid {                 // "Sort" before the picker, as in the full-width row.                 widgets::hud(ui, tl!("Sort"), t.faint);                 self.sort_picker(ui);             }-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+            ui.with_layout(inner, |ui| {                 if self.caps.manage_apps && updates > 0 {                     self.update_all_button(ui, updates);                 }@@ -364,12 +394,15 @@ impl LauncherUi {         }));         let icon = 13.0;         let spacing = ui.spacing().item_spacing.x;-        // The icons on the right keep their size; the status on the left gets what is left and+        let rtl = current().rtl();+        let outer = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };+        let inner = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };+        // The icons on the opposite edge keep their size; the status gets what is left and         // is cut short with "…", never drawn under them.-        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+        ui.allocate_ui_with_layout(vec2(ui.available_width(), ui.spacing().interact_size.y), outer, |ui| {             widgets::icon_links(ui, icon, &links);             ui.add_space(8.0);-            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {+            ui.with_layout(inner, |ui| {                 // While GitHub is rate limited, saying so matters more than when we last checked.                 let avail = ui.available_width();                 let text = |s: &str| RichText::new(s).size(12.0).color(t.warn);@@ -404,17 +437,25 @@ impl LauncherUi {         if !top.is_empty() && !rest.is_empty() {             let t = Tokens::get(ui.ctx());             ui.add_space(28.0);-            widgets::hud(ui, tl!("Not installed"), t.faint);+            if current().rtl() {+                ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {+                    widgets::hud(ui, tl!("Not installed"), t.faint);+                });+            } else {+                widgets::hud(ui, tl!("Not installed"), t.faint);+            }             ui.add_space(12.0);         }         self.grid_block(ui, rest);         ui.add_space(24.0);     } +    /// Lays out one block of app cards in a shared-hairline grid, ordering columns right-to-left when the UI locale is RTL.     fn grid_block(&mut self, ui: &mut Ui, ids: &[&'static str]) {         if ids.is_empty() {             return;         }+        let rtl = current().rtl();         let width = ui.available_width();         // Neighbouring cards share their 1 px hairline, like the site's `gap-px` grid.         let cols = ((width / CARD_MIN_W).floor() as usize).max(1);@@ -424,7 +465,8 @@ impl LauncherUi {         let (area, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());         for (i, id) in ids.iter().enumerate() {             let (row, col) = (i / cols, i % cols);-            let min = area.min + vec2(col as f32 * (card_w - 1.0), row as f32 * (CARD_H - 1.0));+            let visual_col = if rtl { cols - 1 - col } else { col };+            let min = area.min + vec2(visual_col as f32 * (card_w - 1.0), row as f32 * (CARD_H - 1.0));             self.card(ui, id, Rect::from_min_size(min, vec2(card_w, CARD_H)));         }     }@@ -467,20 +509,22 @@ impl LauncherUi {         }          // The top strip: the version in the app's colour, and its GitHub page.+        let rtl = current().rtl();+        let align = if rtl { egui::Align::Max } else { egui::Align::Min };         let status = entry.status(self.platform, self.caps.manage_apps);         let version = crate::card_parts::strip_version(&entry, &status);         let strip = Rect::from_min_size(rect.min, vec2(rect.width(), STRIP_H));         painter.hline(strip.x_range(), strip.bottom() - 0.5, t.hairline());         {-            let mut strip_ui = ui.new_child(-                egui::UiBuilder::new().max_rect(strip.shrink2(vec2(CARD_PAD, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)),-            );+            let strip_layout = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };+            let link_layout = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };+            let mut strip_ui = ui.new_child(egui::UiBuilder::new().max_rect(strip.shrink2(vec2(CARD_PAD, 0.0))).layout(strip_layout));             if !version.is_empty() {                 let github_w = widgets::hud_galley(&strip_ui, &format!("{} ↗", tl!("GitHub")), t.muted).size().x;                 let room = (strip_ui.available_width() - github_w - 16.0).max(0.0);                 widgets::hud_elided(&mut strip_ui, &version, if in_colour { t.app_ink(app) } else { t.muted }, room);             }-            strip_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+            strip_ui.with_layout(link_layout, |ui| {                 ui.spacing_mut().item_spacing.x = 16.0;                 widgets::hud_link(ui, tl!("GitHub"), &app.repo_url());             });@@ -488,7 +532,7 @@ impl LauncherUi {          let inner = Rect::from_min_max(egui::pos2(rect.left() + CARD_PAD, strip.bottom() + 18.0), rect.max - vec2(CARD_PAD, CARD_PAD));         // A detached child: the card's contents never move the next card along.-        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(align)));         child.set_clip_rect(rect.intersect(ui.clip_rect()));         let ui = &mut child;         ui.horizontal(|ui| {@@ -545,7 +589,7 @@ impl LauncherUi {             ui.add_space(6.0);             ui.add(egui::Label::new(RichText::new(line).size(12.5).color(t.faint)).selectable(false));         }-        ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {+        ui.with_layout(egui::Layout::bottom_up(align), |ui| {             ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width()));         });     }@@ -1135,7 +1179,8 @@ pub(crate) fn launcher_download(r: &artcraft_launcher_core::Release, p: artcraft /// Each line is measured as an empty galley, which is rounded to the pixel like a label, unlike `Fonts::row_height`. fn centered_lines(ui: &mut Ui, fonts: &[&FontId], gap: f32, add_contents: impl FnOnce(&mut Ui)) {     let height = lines_height(ui, fonts, gap);-    ui.allocate_ui_with_layout(vec2(ui.available_width(), height), egui::Layout::top_down(egui::Align::Min), |ui| {+    let align = if current().rtl() { egui::Align::Max } else { egui::Align::Min };+    ui.allocate_ui_with_layout(vec2(ui.available_width(), height), egui::Layout::top_down(align), |ui| {         ui.spacing_mut().item_spacing.y = gap;         add_contents(ui);     });diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs--- a/crates/ui-egui/src/widgets.rs+++ b/crates/ui-egui/src/widgets.rs@@ -23,7 +23,11 @@ pub fn tone_color(t: &Tokens, tone: Tone) -> Color32 { /// `text` laid out upper case in `family`, `spacing` points between letters. pub fn caps(ui: &Ui, text: &str, size: f32, family: egui::FontFamily, spacing: f32, color: Color32) -> Arc<Galley> {     let format = egui::TextFormat { font_id: egui::FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() };-    ui.painter().layout_job(egui::text::LayoutJob::single_section(text.to_uppercase(), format))+    let mut job = egui::text::LayoutJob::single_section(text.to_uppercase(), format);+    if crate::i18n::current().rtl() {+        job.direction = egui::epaint::text::TextDirection::RightToLeft;+    }+    ui.painter().layout_job(job) }  /// A `.hud-label`: 11 px Geist Mono, upper case, widely spaced.@@ -61,6 +65,9 @@ pub fn hud_elided(ui: &mut Ui, text: &str, color: Color32, max_width: f32) -> Re         ..Default::default()     };     let mut job = egui::text::LayoutJob::single_section(text.to_uppercase(), format);+    if crate::i18n::current().rtl() {+        job.direction = egui::epaint::text::TextDirection::RightToLeft;+    }     job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.min(ui.available_width()).max(0.0));     let galley = ui.painter().layout_job(job);     let elided = galley.elided;@@ -659,7 +666,7 @@ pub fn wordmark(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f32) pub fn wordmark_in(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f32, in_colour: bool) -> Response {     let t = Tokens::get(ui.ctx());     let (prefix, craft) = app.name.strip_suffix("Craft").map_or((app.name, ""), |p| (p, "Craft"));-    let mut job = egui::text::LayoutJob::default();+    let mut job = egui::text::LayoutJob { direction: egui::epaint::text::TextDirection::LeftToRight, ..Default::default() };     let format = |color| egui::TextFormat {         font_id: egui::FontId::new(size, theme::display()),         color,