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
@@ -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);@@ -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('…');@@ -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.@@ -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() } @@ -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".@@ -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); }@@ -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); }@@ -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;@@ -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); });@@ -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,