From 903dbd5fa342c4df1cd9fe81982b0440ae007f4b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:38:22 +0000 Subject: [PATCH 01/35] i18n: scripts, direction and regional Han per language tag scripts(tag) lists the ISO 15924 scripts a language's interface text is written in, direction(tag) follows the main script, and han(tag) gives the regional Han forms (SC, TC, HK, JP, KR) for picking a CJK face. I18n::rtl uses direction. --- crates/i18n/README.md | 8 +- crates/i18n/src/lib.rs | 11 +- crates/i18n/src/scripts.rs | 363 +++++++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 crates/i18n/src/scripts.rs diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df337971..cde3621d 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -60,8 +60,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l `plural_form_counts` holds the same counts. - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". -- **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and - `yi`; putting text into display order is the app's job. +- **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its + interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn), `direction` is right + to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional + forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → SC, `zh-TW`/ + `zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the + language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4ef..84295a16 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod scripts; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use scripts::{Direction, Han, Script, direction, han, scripts}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; @@ -199,12 +202,10 @@ impl I18n { self.locale(lang).and_then(|l| l.rows.get("@id")?.get("language.name")).map_or_else(|| self.code(lang), String::as_str) } - /// Whether the language is written right to left. Putting text into display order is the - /// app's job. + /// Whether the language is written right to left ([`direction`]). Putting text into display + /// order is the app's job. pub fn rtl(&self, lang: Lang) -> bool { - let code = self.code(lang); - let language = code.split('-').next().unwrap_or(code); - matches!(language, "ar" | "ckb" | "dv" | "fa" | "he" | "ps" | "sd" | "ug" | "ur" | "yi") + direction(self.code(lang)) == Direction::Rtl } /// The language for a system locale tag (`fr_CA.UTF-8`, `zh-Hant-MO`); `C` and `POSIX` are diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs new file mode 100644 index 00000000..ecd2f8dd --- /dev/null +++ b/crates/i18n/src/scripts.rs @@ -0,0 +1,363 @@ +//! The scripts a language is written in, its direction, and which regional forms its Han +//! characters take, for picking fonts and laying out the interface. + +/// A writing system, by its ISO 15924 code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Script { + Arab, + Armn, + Beng, + Cyrl, + Deva, + Ethi, + Geor, + Grek, + Gujr, + Guru, + Hang, + Hani, + Hebr, + Hira, + Kana, + Khmr, + Knda, + Laoo, + Latn, + Mlym, + Mong, + Mymr, + Orya, + Sinh, + Syrc, + Taml, + Telu, + Thaa, + Thai, + Tibt, +} + +impl Script { + /// The ISO 15924 code, `Latn`. + pub fn code(self) -> &'static str { + match self { + Script::Arab => "Arab", + Script::Armn => "Armn", + Script::Beng => "Beng", + Script::Cyrl => "Cyrl", + Script::Deva => "Deva", + Script::Ethi => "Ethi", + Script::Geor => "Geor", + Script::Grek => "Grek", + Script::Gujr => "Gujr", + Script::Guru => "Guru", + Script::Hang => "Hang", + Script::Hani => "Hani", + Script::Hebr => "Hebr", + Script::Hira => "Hira", + Script::Kana => "Kana", + Script::Khmr => "Khmr", + Script::Knda => "Knda", + Script::Laoo => "Laoo", + Script::Latn => "Latn", + Script::Mlym => "Mlym", + Script::Mong => "Mong", + Script::Mymr => "Mymr", + Script::Orya => "Orya", + Script::Sinh => "Sinh", + Script::Syrc => "Syrc", + Script::Taml => "Taml", + Script::Telu => "Telu", + Script::Thaa => "Thaa", + Script::Thai => "Thai", + Script::Tibt => "Tibt", + } + } + + /// Whether text in this script runs right to left. + pub fn rtl(self) -> bool { + matches!(self, Script::Arab | Script::Hebr | Script::Syrc | Script::Thaa) + } +} + +/// Which way a language's text, and an interface in it, runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Direction { + Ltr, + Rtl, +} + +/// The regional glyph forms Han characters take, as in the Noto CJK face names (SC, TC, HK, JP, +/// KR). The same code point is drawn differently in each, so a font stack picks its Han face by +/// this, not by script. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Han { + /// Simplified Chinese, mainland China and Singapore (SC). + Simplified, + /// Traditional Chinese, Taiwan (TC). + Traditional, + /// Traditional Chinese, Hong Kong and Macau (HK). + HongKong, + /// Japanese kanji (JP). + Japanese, + /// Korean hanja (KR). + Korean, +} + +/// The scripts a language tag's interface text is written in (`ja` → Hani, Hira, Kana; `sr` → +/// Cyrl; `sr-latn` → Latn), the main one first. Any case and glibc form (`sr_RS.UTF-8@latin`). A +/// script subtag wins over the language's usual script. Latin product names and units appear in +/// every language, so Latn is listed only where it is the language's own script. A language not +/// in the table is taken to be written in Latin. +// ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. +pub fn scripts(tag: &str) -> &'static [Script] { + use Script::*; + let tag = Subtags::parse(tag); + if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { + return s; + } + match tag.language.as_str() { + "ja" => &[Hani, Hira, Kana], + "ko" => &[Hang, Hani], + "zh" | "yue" | "wuu" => &[Hani], + "be" | "bg" | "ba" | "ce" | "cv" | "kk" | "ky" | "mk" | "mn" | "ru" | "sah" | "sr" | "tg" | "tt" | "uk" => &[Cyrl], + "el" => &[Grek], + "ar" | "ckb" | "fa" | "ks" | "ps" | "sd" | "ug" | "ur" => &[Arab], + "he" | "yi" => &[Hebr], + "syr" => &[Syrc], + "dv" => &[Thaa], + "ka" => &[Geor], + "hy" => &[Armn], + "hi" | "mr" | "ne" | "sa" | "kok" | "mai" | "bho" => &[Deva], + "bn" | "as" | "mni" => &[Beng], + "pa" => &[Guru], + "gu" => &[Gujr], + "or" => &[Orya], + "ta" => &[Taml], + "te" => &[Telu], + "kn" => &[Knda], + "ml" => &[Mlym], + "si" => &[Sinh], + "th" => &[Thai], + "lo" => &[Laoo], + "km" => &[Khmr], + "my" => &[Mymr], + "am" | "ti" => &[Ethi], + "bo" | "dz" => &[Tibt], + _ => &[Latn], + } +} + +/// The direction of a language tag's text: right to left when its main script is. +pub fn direction(tag: &str) -> Direction { + match scripts(tag).first() { + Some(s) if s.rtl() => Direction::Rtl, + _ => Direction::Ltr, + } +} + +/// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → +/// Simplified; `zh-TW` and `zh-Hant` → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; +/// `ja` → Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the +/// choice to the font stack. +pub fn han(tag: &str) -> Option { + let tag = Subtags::parse(tag); + let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); + match (tag.language.as_str(), tag.script.as_deref()) { + ("ja", _) => Some(Han::Japanese), + ("ko", _) => Some(Han::Korean), + ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), + ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), + // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. + ("yue", _) => Some(if tag.region.as_deref() == Some("cn") { Han::Simplified } else { Han::HongKong }), + ("zh" | "wuu", _) => Some(match tag.region.as_deref() { + Some("tw") => Han::Traditional, + _ if hong_kong => Han::HongKong, + _ => Han::Simplified, + }), + _ => None, + } +} + +/// The scripts a script subtag stands for, including the CLDR unions (`jpan`, `kore`). `None` for +/// one not in [`Script`], so the language's own scripts apply. +fn subtag_scripts(subtag: &str) -> Option<&'static [Script]> { + use Script::*; + Some(match subtag { + "hans" | "hant" | "hani" => &[Hani], + "jpan" => &[Hani, Hira, Kana], + "kore" => &[Hang, Hani], + "arab" => &[Arab], + "armn" => &[Armn], + "beng" => &[Beng], + "cyrl" => &[Cyrl], + "deva" => &[Deva], + "ethi" => &[Ethi], + "geor" => &[Geor], + "grek" => &[Grek], + "gujr" => &[Gujr], + "guru" => &[Guru], + "hang" => &[Hang], + "hebr" => &[Hebr], + "khmr" => &[Khmr], + "knda" => &[Knda], + "laoo" => &[Laoo], + "latn" => &[Latn], + "mlym" => &[Mlym], + "mong" => &[Mong], + "mymr" => &[Mymr], + "orya" => &[Orya], + "sinh" => &[Sinh], + "syrc" => &[Syrc], + "taml" => &[Taml], + "telu" => &[Telu], + "thaa" => &[Thaa], + "thai" => &[Thai], + "tibt" => &[Tibt], + _ => return None, + }) +} + +/// A language tag's language, script and region subtags, lowercase. +struct Subtags { + language: String, + script: Option, + region: Option, +} + +impl Subtags { + /// `zh-Hant-HK`, `zh_TW.UTF-8` or `sr_RS@latin` (glibc's script modifier). + fn parse(tag: &str) -> Subtags { + let tag = tag.trim().to_ascii_lowercase(); + let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-"); + let mut parts = base.split('-').filter(|p| !p.is_empty()); + let language = parts.next().unwrap_or("").to_owned(); + let mut script = match tag.split_once('@').map(|(_, m)| m) { + Some("latin") => Some("latn".to_owned()), + Some("cyrillic") => Some("cyrl".to_owned()), + _ => None, + }; + let mut region = None; + for part in parts { + if part.len() == 4 && part.bytes().all(|b| b.is_ascii_alphabetic()) { + script.get_or_insert_with(|| part.to_owned()); + } else if super::is_region(part) { + region = Some(part.to_owned()); + break; + } + } + Subtags { language, script, region } + } +} + +#[cfg(test)] +mod tests { + use super::Script::*; + use super::*; + + /// Every locale the craft-i18n catalogs or an app ship, as the apps spell them. + #[test] + fn shipped_locales() { + for (tag, want, han_forms) in [ + ("en", &[Latn][..], None), + ("ar", &[Arab], None), + ("bg", &[Cyrl], None), + ("cs", &[Latn], None), + ("da", &[Latn], None), + ("de", &[Latn], None), + ("el", &[Grek], None), + ("es", &[Latn], None), + ("es-419", &[Latn], None), + ("et", &[Latn], None), + ("fi", &[Latn], None), + ("fr", &[Latn], None), + ("hr", &[Latn], None), + ("hu", &[Latn], None), + ("id", &[Latn], None), + ("it", &[Latn], None), + ("ja", &[Hani, Hira, Kana], Some(Han::Japanese)), + ("ka", &[Geor], None), + ("kk", &[Cyrl], None), + ("ko", &[Hang, Hani], Some(Han::Korean)), + ("lt", &[Latn], None), + ("nl", &[Latn], None), + ("pl", &[Latn], None), + ("pt", &[Latn], None), + ("pt-br", &[Latn], None), + ("pt_BR", &[Latn], None), + ("ru", &[Cyrl], None), + ("sl", &[Latn], None), + ("sr", &[Cyrl], None), + ("sr-latn", &[Latn], None), + ("sv", &[Latn], None), + ("te", &[Telu], None), + ("tr", &[Latn], None), + ("uk", &[Cyrl], None), + ("vi", &[Latn], None), + ("zh", &[Hani], Some(Han::Simplified)), + ("zh-cn", &[Hani], Some(Han::Simplified)), + ("zh-CN", &[Hani], Some(Han::Simplified)), + ("zh-hans", &[Hani], Some(Han::Simplified)), + ("zh-Hans", &[Hani], Some(Han::Simplified)), + ("zh-hant", &[Hani], Some(Han::Traditional)), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + assert_eq!(han(tag), han_forms, "{tag}"); + let rtl = tag == "ar"; + assert_eq!(direction(tag), if rtl { Direction::Rtl } else { Direction::Ltr }, "{tag}"); + } + } + + #[test] + fn regional_han() { + for (tag, want) in [ + ("zh-TW", Han::Traditional), + ("zh_TW.UTF-8", Han::Traditional), + ("zh-Hant-TW", Han::Traditional), + ("zh-HK", Han::HongKong), + ("zh-MO", Han::HongKong), + ("zh-Hant-HK", Han::HongKong), + ("zh-hant-hk", Han::HongKong), + ("zh-Hans-HK", Han::Simplified), + ("zh-SG", Han::Simplified), + ("zh-Hans-SG", Han::Simplified), + ("yue", Han::HongKong), + ("yue-Hans", Han::Simplified), + ("ja-JP", Han::Japanese), + ("ko_KR.UTF-8", Han::Korean), + ] { + assert_eq!(han(tag), Some(want), "{tag}"); + } + assert_eq!(han("en"), None); + assert_eq!(han(""), None); + } + + #[test] + fn script_subtag_wins() { + for (tag, want) in [ + ("sr-Latn-RS", &[Latn][..]), + ("sr_RS@latin", &[Latn]), + ("sr-Cyrl", &[Cyrl]), + ("sr_RS.UTF-8", &[Cyrl]), + ("az-Cyrl", &[Cyrl]), + ("pa-Arab", &[Arab]), + ("pa", &[Guru]), + ("mn-Mong", &[Mong]), + ("sd-Deva", &[Deva]), + ("fr-Zyyy", &[Latn]), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + } + assert_eq!(direction("pa-Arab"), Direction::Rtl); + assert_eq!(direction("sd-Deva"), Direction::Ltr); + assert_eq!(direction("he_IL.UTF-8"), Direction::Rtl); + } + + /// The usual scripts tag matching relies on agree with the table. + #[test] + fn agrees_with_usual_script() { + for language in ["be", "mn", "sr", "zh", "az", "bs", "ha", "uz", "ks", "sd", "pa"] { + let usual = crate::usual_script(language).and_then(subtag_scripts); + assert_eq!(usual, Some(scripts(language)), "{language}"); + } + } +} From 75b97a9192da103a0e0c6f782de307b42b7b9ec1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:42:51 +0000 Subject: [PATCH 02/35] i18n: non_exhaustive Script and Han; regions that imply a script pa-PK, uz-AF and az-IR are written in Arabic and sd-IN in Devanagari, as CLDR's likely subtags give them. A script subtag still wins. --- crates/i18n/README.md | 10 +++---- crates/i18n/src/scripts.rs | 59 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index cde3621d..ac6146b5 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -61,11 +61,11 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". - **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its - interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn), `direction` is right - to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional - forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → SC, `zh-TW`/ - `zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the - language's code; putting text into display order is the app's job. + interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab), + `direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` + gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → + SC, `zh-TW`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is + `direction` of the language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index ecd2f8dd..037b3d40 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -3,6 +3,7 @@ /// A writing system, by its ISO 15924 code. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] pub enum Script { Arab, Armn, @@ -90,6 +91,7 @@ pub enum Direction { /// KR). The same code point is drawn differently in each, so a font stack picks its Han face by /// this, not by script. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum Han { /// Simplified Chinese, mainland China and Singapore (SC). Simplified, @@ -105,9 +107,10 @@ pub enum Han { /// The scripts a language tag's interface text is written in (`ja` → Hani, Hira, Kana; `sr` → /// Cyrl; `sr-latn` → Latn), the main one first. Any case and glibc form (`sr_RS.UTF-8@latin`). A -/// script subtag wins over the language's usual script. Latin product names and units appear in -/// every language, so Latn is listed only where it is the language's own script. A language not -/// in the table is taken to be written in Latin. +/// script subtag wins, then a region that implies a script (`pa-PK` → Arab), then the language's +/// usual script. Latin product names and units appear in every language, so Latn is listed only +/// where it is the language's own script. A language not in the table is taken to be written in +/// Latin. // ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. pub fn scripts(tag: &str) -> &'static [Script] { use Script::*; @@ -115,6 +118,9 @@ pub fn scripts(tag: &str) -> &'static [Script] { if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { return s; } + if let Some(s) = tag.region.as_deref().and_then(|region| region_scripts(&tag.language, region)) { + return s; + } match tag.language.as_str() { "ja" => &[Hani, Hira, Kana], "ko" => &[Hang, Hani], @@ -178,6 +184,21 @@ pub fn han(tag: &str) -> Option { } } +/// The scripts a language takes in a region where CLDR's likely subtags give it a script other +/// than its usual one (`pa-PK` → `pa-Arab-PK`). Han regions are left to [`han`]. +// ponytail: mn-CN → Mong is left out until vertical Mongolian layout is supported. +fn region_scripts(language: &str, region: &str) -> Option<&'static [Script]> { + use Script::*; + Some(match (language, region) { + ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => &[Arab], + ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => &[Arab], + ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => &[Cyrl], + ("ky", "tr") | ("sr", "me" | "ro" | "tr") => &[Latn], + ("sd", "in") => &[Deva], + _ => return None, + }) +} + /// The scripts a script subtag stands for, including the CLDR unions (`jpan`, `kore`). `None` for /// one not in [`Script`], so the language's own scripts apply. fn subtag_scripts(subtag: &str) -> Option<&'static [Script]> { @@ -352,6 +373,38 @@ mod tests { assert_eq!(direction("he_IL.UTF-8"), Direction::Rtl); } + /// CLDR likely subtags: a region can switch a language to another script. + #[test] + fn region_implies_script() { + for (tag, want) in [ + ("pa-PK", &[Arab][..]), + ("pa_PK.UTF-8", &[Arab]), + ("pa-IN", &[Guru]), + ("uz-AF", &[Arab]), + ("uz-UZ", &[Latn]), + ("uz-CN", &[Cyrl]), + ("az-IR", &[Arab]), + ("az-AZ", &[Latn]), + ("sd-IN", &[Deva]), + ("sd-PK", &[Arab]), + ("ks-IN", &[Arab]), + ("ms-CC", &[Arab]), + ("ms-BN", &[Latn]), + ("sr-ME", &[Latn]), + ("kk-CN", &[Arab]), + ("ug-KZ", &[Cyrl]), + ("pa-Guru-PK", &[Guru]), + ("sd-Arab-IN", &[Arab]), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + } + assert_eq!(direction("pa-PK"), Direction::Rtl); + assert_eq!(direction("uz-AF"), Direction::Rtl); + assert_eq!(direction("az-IR"), Direction::Rtl); + assert_eq!(direction("sd-IN"), Direction::Ltr); + assert_eq!(direction("ug-KZ"), Direction::Ltr); + } + /// The usual scripts tag matching relies on agree with the table. #[test] fn agrees_with_usual_script() { From 502f7997167d3602920b63ac73be9f89c409ab17 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:54:12 +0000 Subject: [PATCH 03/35] i18n: han and catalog matching follow region-implied scripts han(zh-US), zh-AU and the other regions CLDR writes in Traditional characters give Traditional, and pa-PK picks a pa-arab catalog, never Gurmukhi pa, as pa-Arab-PK does. --- crates/i18n/README.md | 10 ++++----- crates/i18n/src/lib.rs | 30 +++++++++++++++++++++++-- crates/i18n/src/scripts.rs | 46 +++++++++++++++++++++----------------- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index ac6146b5..4bf29ace 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -64,14 +64,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab), `direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → - SC, `zh-TW`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is + SC, `zh-TW`/`zh-US`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and - Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → - `zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script - is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` - picks `sr`. + Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → `zh-hant`, + `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never + crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`. + A region that implies a script counts as that script: `pa-PK` picks `pa-arab`, not Gurmukhi `pa`. - **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get` included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 84295a16..6c841d1e 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -292,7 +292,9 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { /// Chinese without a script picks one by region; Portuguese falls back to `pt-br` last, when /// there is no `pt`. A glibc script modifier (`sr_RS@latin`) becomes the script subtag. With a /// script (`sr-Latn-RS`), a code without one is tried only when that is its language's usual -/// script, so Latin Serbian never picks Cyrillic `sr`, while `sr-Cyrl-RS` does. +/// script, so Latin Serbian never picks Cyrillic `sr`, while `sr-Cyrl-RS` does. A region that +/// implies a script (CLDR likely subtags) counts as that script after the tag as written: +/// `pa-PK` → `pa-pk`, `pa-arab-pk`, `pa-arab`. fn candidates(tag: &str) -> Vec { let tag = tag.trim().to_ascii_lowercase(); let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-"); @@ -309,6 +311,16 @@ fn candidates(tag: &str) -> Vec { parts.insert(1, s); } let script = parts.get(1).copied().filter(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic())); + // Chinese picks its script by region below, keeping plain `zh` as the last resort. + if script.is_none() + && primary != "zh" + && let Some(implied) = parts.get(1).filter(|r| is_region(r)).and_then(|r| scripts::region_script(primary, r)) + { + let mut out = vec![parts.join("-")]; + parts.insert(1, implied); + out.extend(candidates(&parts.join("-"))); + return out; + } let mut out: Vec = (1..=parts.len()).rev().map(|n| parts.get(..n).unwrap_or_default().join("-")).collect(); if let Some(s) = script && usual_script(primary).is_some_and(|usual| usual != s) @@ -316,7 +328,8 @@ fn candidates(tag: &str) -> Vec { out.retain(|c| c.split('-').any(|p| p == s)); } if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) { - let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" }; + let hant = parts.iter().skip(1).any(|p| scripts::region_script("zh", p) == Some("hant")); + let script = if hant { "zh-hant" } else { "zh-hans" }; out.insert(out.len().saturating_sub(1), script.to_owned()); } if primary == "pt" && !out.iter().any(|c| c == "pt-br") { @@ -565,6 +578,8 @@ mod tests { ("zh", Some("zh-hans")), ("zh-Hant-MO", Some("zh-hant")), ("zh_CN", Some("zh-hans")), + ("zh-US", Some("zh-hant")), + ("zh_SG", Some("zh-hans")), ("C", Some("en")), ("POSIX", Some("en")), ("en_US", Some("en")), @@ -576,6 +591,17 @@ mod tests { } } + #[test] + fn region_implies_script() { + assert_eq!(candidates("pa-PK"), ["pa-pk", "pa-arab-pk", "pa-arab"]); + assert_eq!(candidates("pa_PK.UTF-8"), ["pa-pk", "pa-arab-pk", "pa-arab"]); + assert_eq!(candidates("pa-IN"), ["pa-in", "pa"]); + assert_eq!(candidates("sd-IN"), ["sd-in", "sd-deva-in", "sd-deva"]); + assert_eq!(candidates("pa-Guru-PK"), ["pa-guru-pk", "pa-guru", "pa"]); + assert_eq!(candidates("zh-US"), ["zh-us", "zh-hant", "zh"]); + assert_eq!(candidates("zh-SG"), ["zh-sg", "zh-hans", "zh"]); + } + #[test] fn match_tags_takes_first_match() { let i = all(L).0; diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index 037b3d40..56a1ba1f 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -118,7 +118,7 @@ pub fn scripts(tag: &str) -> &'static [Script] { if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { return s; } - if let Some(s) = tag.region.as_deref().and_then(|region| region_scripts(&tag.language, region)) { + if let Some(s) = tag.region.as_deref().and_then(|region| region_script(&tag.language, region)).and_then(subtag_scripts) { return s; } match tag.language.as_str() { @@ -162,39 +162,38 @@ pub fn direction(tag: &str) -> Direction { } /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → -/// Simplified; `zh-TW` and `zh-Hant` → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; -/// `ja` → Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the -/// choice to the font stack. +/// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters +/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; `ja` → +/// Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the choice +/// to the font stack. pub fn han(tag: &str) -> Option { let tag = Subtags::parse(tag); let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); - match (tag.language.as_str(), tag.script.as_deref()) { + let script = tag.script.as_deref().or_else(|| region_script(&tag.language, tag.region.as_deref()?)); + match (tag.language.as_str(), script) { ("ja", _) => Some(Han::Japanese), ("ko", _) => Some(Han::Korean), ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. - ("yue", _) => Some(if tag.region.as_deref() == Some("cn") { Han::Simplified } else { Han::HongKong }), - ("zh" | "wuu", _) => Some(match tag.region.as_deref() { - Some("tw") => Han::Traditional, - _ if hong_kong => Han::HongKong, - _ => Han::Simplified, - }), + ("yue", _) => Some(Han::HongKong), + ("zh" | "wuu", _) => Some(Han::Simplified), _ => None, } } -/// The scripts a language takes in a region where CLDR's likely subtags give it a script other -/// than its usual one (`pa-PK` → `pa-Arab-PK`). Han regions are left to [`han`]. +/// The script subtag of a language in a region where CLDR's likely subtags give it a script +/// other than its usual one: `pa-PK` → `arab` (`pa-Arab-PK`), `zh-TW` → `hant`. // ponytail: mn-CN → Mong is left out until vertical Mongolian layout is supported. -fn region_scripts(language: &str, region: &str) -> Option<&'static [Script]> { - use Script::*; +pub(crate) fn region_script(language: &str, region: &str) -> Option<&'static str> { Some(match (language, region) { - ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => &[Arab], - ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => &[Arab], - ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => &[Cyrl], - ("ky", "tr") | ("sr", "me" | "ro" | "tr") => &[Latn], - ("sd", "in") => &[Deva], + ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => "arab", + ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => "arab", + ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => "cyrl", + ("ky", "tr") | ("sr", "me" | "ro" | "tr") => "latn", + ("sd", "in") => "deva", + ("zh", "au" | "bn" | "gb" | "gf" | "hk" | "id" | "mo" | "pa" | "pf" | "ph" | "sr" | "th" | "tw" | "us" | "vn") => "hant", + ("yue", "cn") => "hans", _ => return None, }) } @@ -340,6 +339,13 @@ mod tests { ("zh-hant-hk", Han::HongKong), ("zh-Hans-HK", Han::Simplified), ("zh-SG", Han::Simplified), + ("zh-US", Han::Traditional), + ("zh_AU.UTF-8", Han::Traditional), + ("zh-GB", Han::Traditional), + ("zh-Hans-US", Han::Simplified), + ("zh-MY", Han::Simplified), + ("yue-CN", Han::Simplified), + ("yue-HK", Han::HongKong), ("zh-Hans-SG", Han::Simplified), ("yue", Han::HongKong), ("yue-Hans", Han::Simplified), From c9013e08c7752a12c14f228e2d45ebf452c96a5b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:36 +0000 Subject: [PATCH 04/35] i18n: Hani and yue-Hant pick Han forms by region; usual scripts for kk, ky, tg, ku, ms, ug --- crates/i18n/src/lib.rs | 9 ++++++--- crates/i18n/src/scripts.rs | 15 +++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 6c841d1e..db13b6db 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -358,10 +358,10 @@ fn marked(text: &str) -> String { // ponytail: the multi-script languages a Craft app could plausibly ship; add a row with the locale. fn usual_script(language: &str) -> Option<&'static str> { Some(match language { - "be" | "mn" | "sr" => "cyrl", + "be" | "kk" | "ky" | "mn" | "sr" | "tg" => "cyrl", "zh" => "hans", - "az" | "bs" | "ha" | "uz" => "latn", - "ks" | "sd" => "arab", + "az" | "bs" | "ha" | "ku" | "ms" | "uz" => "latn", + "ks" | "sd" | "ug" => "arab", "pa" => "guru", _ => return None, }) @@ -600,6 +600,9 @@ mod tests { assert_eq!(candidates("pa-Guru-PK"), ["pa-guru-pk", "pa-guru", "pa"]); assert_eq!(candidates("zh-US"), ["zh-us", "zh-hant", "zh"]); assert_eq!(candidates("zh-SG"), ["zh-sg", "zh-hans", "zh"]); + assert_eq!(candidates("kk-CN"), ["kk-cn", "kk-arab-cn", "kk-arab"]); + assert_eq!(candidates("kk-KZ"), ["kk-kz", "kk"]); + assert_eq!(candidates("ug-KZ"), ["ug-kz", "ug-cyrl-kz", "ug-cyrl"]); } #[test] diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index 56a1ba1f..ebe324f5 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -163,18 +163,21 @@ pub fn direction(tag: &str) -> Direction { /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → /// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters -/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; `ja` → +/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO`, `zh-Hant-HK` and `yue` → HongKong; `ja` → /// Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the choice /// to the font stack. pub fn han(tag: &str) -> Option { let tag = Subtags::parse(tag); let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); - let script = tag.script.as_deref().or_else(|| region_script(&tag.language, tag.region.as_deref()?)); + // `Hani` names Han without saying which forms, so the region still decides. + let script = tag.script.as_deref().filter(|s| *s != "hani"); + let script = script.or_else(|| region_script(&tag.language, tag.region.as_deref()?)); match (tag.language.as_str(), script) { ("ja", _) => Some(Han::Japanese), ("ko", _) => Some(Han::Korean), ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), - ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), + ("yue", Some("hant")) => Some(if tag.region.as_deref() == Some("tw") { Han::Traditional } else { Han::HongKong }), + ("zh" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. ("yue", _) => Some(Han::HongKong), ("zh" | "wuu", _) => Some(Han::Simplified), @@ -348,6 +351,10 @@ mod tests { ("yue-HK", Han::HongKong), ("zh-Hans-SG", Han::Simplified), ("yue", Han::HongKong), + ("yue-Hant", Han::HongKong), + ("yue-Hant-TW", Han::Traditional), + ("zh-Hani-HK", Han::HongKong), + ("zh-Hani", Han::Simplified), ("yue-Hans", Han::Simplified), ("ja-JP", Han::Japanese), ("ko_KR.UTF-8", Han::Korean), @@ -414,7 +421,7 @@ mod tests { /// The usual scripts tag matching relies on agree with the table. #[test] fn agrees_with_usual_script() { - for language in ["be", "mn", "sr", "zh", "az", "bs", "ha", "uz", "ks", "sd", "pa"] { + for language in ["be", "kk", "ky", "mn", "sr", "tg", "zh", "az", "bs", "ha", "ku", "ms", "uz", "ks", "sd", "ug", "pa"] { let usual = crate::usual_script(language).and_then(subtag_scripts); assert_eq!(usual, Some(scripts(language)), "{language}"); } From 2ff3f1165410a75d9051e9679446b8711f8a097b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:13:49 +0000 Subject: [PATCH 05/35] i18n: base_direction of a paragraph from its first strong character --- Cargo.lock | 1 + crates/i18n/Cargo.toml | 1 + crates/i18n/src/lib.rs | 2 +- crates/i18n/src/scripts.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4d9de428..ebc4055f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,7 @@ dependencies = [ "intl_pluralrules", "sys-locale", "unic-langid", + "unicode-bidi", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775..8283943f 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true intl_pluralrules = "=7.0.2" unic-langid = "0.9" sys-locale = "0.3" +unicode-bidi = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index db13b6db..ca955f85 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -14,7 +14,7 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; -pub use scripts::{Direction, Han, Script, direction, han, scripts}; +pub use scripts::{Direction, Han, Script, base_direction, direction, han, scripts}; /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index ebe324f5..1ae0f29b 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -161,6 +161,18 @@ pub fn direction(tag: &str) -> Direction { } } +/// The direction of a paragraph of text, from its first strong character (UAX #9 rules P2 and +/// P3): the contents of isolates (LRI, RLI or FSI up to the matching PDI) are skipped, and the +/// search stops at the first paragraph separator. `None` when the paragraph has no strong +/// character; P3 then reads it as left to right, and an app may use the interface direction. +pub fn base_direction(text: &str) -> Option { + match unicode_bidi::get_base_direction(text) { + unicode_bidi::Direction::Ltr => Some(Direction::Ltr), + unicode_bidi::Direction::Rtl => Some(Direction::Rtl), + unicode_bidi::Direction::Mixed => None, + } +} + /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → /// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters /// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO`, `zh-Hant-HK` and `yue` → HongKong; `ja` → @@ -277,6 +289,20 @@ mod tests { use super::Script::*; use super::*; + #[test] + fn base_direction_of_text() { + assert_eq!(base_direction("Hello"), Some(Direction::Ltr)); + assert_eq!(base_direction("123 שלום"), Some(Direction::Rtl)); + assert_eq!(base_direction("(مرحبا) hi"), Some(Direction::Rtl)); + // The isolate's Hebrew is skipped; the Latin after it decides. + assert_eq!(base_direction("\u{2067}שלום\u{2069} hello"), Some(Direction::Ltr)); + assert_eq!(base_direction("\u{2068}abc\u{2069} שלום"), Some(Direction::Rtl)); + // Only the first paragraph counts. + assert_eq!(base_direction("123\nשלום"), None); + assert_eq!(base_direction("1920 × 1080"), None); + assert_eq!(base_direction(""), None); + } + /// Every locale the craft-i18n catalogs or an app ship, as the apps spell them. #[test] fn shipped_locales() { From 1f805378d454dac48b5c214029d254055fdf961b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 05:35:20 -0400 Subject: [PATCH 06/35] feat(window): shared craft-window crate for consistent window decorations (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(window): shared crate deciding who draws the window frame Add craft-window, which decides whether the desktop or the app draws the window frame (titlebar, borders, window buttons) and applies it to an egui ViewportBuilder. This fixes the drift where some Craft apps drew their own titlebar on KDE while others kept KWin's. The rule: macOS, Windows and X11 keep the desktop frame; a Wayland compositor with zxdg_decoration_manager_v1 (KDE, Sway, Hyprland, COSMIC) keeps its frame; one without it (GNOME) leaves the frame to the app. _TITLEBAR=app|system|desktop overrides. When the app draws the frame, the decision carries the window-button Layout from GNOME's button-layout so the app's header bar matches the desktop's order. Decision logic only; drawing the buttons stays in each app. Extracted and generalized from pdfcraft's decorations.rs. forbid(unsafe_code); default build has no egui dependency (egui feature adds decorate()). * style(window): rustfmt the Wayland probe and Dispatch impl cargo fmt --check failed CI: collapse the thread-spawn builder chain and the Dispatch::event signature onto the lines rustfmt wants (verified with rustfmt 1.98 against the workspace rustfmt.toml). * fix(window): address review — WAYLAND_SOCKET, bounded gsettings, checkout creds - Skip the Wayland probe when WAYLAND_SOCKET is set: connect_to_env would take ownership of the inherited fd and unset the variable, closing the descriptor winit needs. Keep the desktop frame instead. - Run the gsettings button-layout lookup behind the same 500 ms bounded worker as the Wayland probe, so a stalled gsettings can't block startup. Factored the worker into a shared bounded() helper. - window.yml: set persist-credentials: false on checkout so the token isn't left in .git/config while cargo runs dependency build scripts. - README: document the socket guard and the bounded gsettings lookup. Verified with rustfmt 1.98 and clippy -D warnings (both feature sets). * fix(window): reap the gsettings child on timeout; document probe tradeoffs Follow-up to review: the button-layout lookup now spawns gsettings with piped output, reads it on a worker, and kills+reaps the child whether it finishes or times out — so a stalled gsettings leaves neither a blocked process nor a parked worker (it is a one-shot startup call). Also document, on the Wayland probe, that an inherited WAYLAND_SOCKET (rare; normal launches get WAYLAND_DISPLAY) deliberately falls back to the desktop frame to protect winit's connection — the same frame an app that doesn't probe would get, a safe default rather than a regression. Verified: rustfmt 1.98 clean, clippy -D warnings clean (default + egui), tests pass. --- .github/workflows/window.yml | 36 +++ Cargo.lock | 8 + Cargo.toml | 1 + crates/window/Cargo.toml | 22 ++ crates/window/README.md | 70 ++++++ crates/window/src/lib.rs | 439 +++++++++++++++++++++++++++++++++++ 6 files changed, 576 insertions(+) create mode 100644 .github/workflows/window.yml create mode 100644 crates/window/Cargo.toml create mode 100644 crates/window/README.md create mode 100644 crates/window/src/lib.rs diff --git a/.github/workflows/window.yml b/.github/workflows/window.yml new file mode 100644 index 00000000..61407086 --- /dev/null +++ b/.github/workflows/window.yml @@ -0,0 +1,36 @@ +name: Window decorations + +on: + workflow_dispatch: + push: + branches: [main] + paths: ['crates/window/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/window.yml'] + pull_request: + paths: ['crates/window/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/window.yml'] + +permissions: + contents: read + +jobs: + window: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + # The token isn't needed after checkout; cargo runs build scripts and proc macros from + # dependencies, so don't leave it in .git/config for that third-party code to read. + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt --check + # Default (no egui) and the egui feature both build and lint cleanly. + - run: cargo clippy -p craft-window --all-targets --locked -- -D warnings + - run: cargo clippy -p craft-window --all-targets --locked --features egui -- -D warnings + - run: cargo test -p craft-window --locked -- --nocapture + - run: cargo test -p craft-window --locked --features egui -- --nocapture diff --git a/Cargo.lock b/Cargo.lock index 4d9de428..4c7cda04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -732,6 +732,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-window" +version = "0.1.0" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ca53f61a..319c1138 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } craft-ui = { path = "crates/ui" } +craft-window = { path = "crates/window" } egui = { version = "0.36", default-features = false } rayon = "1" serde = { version = "1", features = ["derive"] } diff --git a/crates/window/Cargo.toml b/crates/window/Cargo.toml new file mode 100644 index 00000000..54db833d --- /dev/null +++ b/crates/window/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "craft-window" +description = "Decide who draws the window frame on Linux (the compositor or the app) and apply it, so every Craft app gets the same native titlebar and borders per desktop." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[features] +# `apply`/`decorations`: decide decorations and set them on an `egui::ViewportBuilder`. +egui = ["dep:egui"] + +[dependencies] +egui = { workspace = true, optional = true } + +# Only Linux and the BSDs probe a Wayland compositor; everywhere else the frame is the OS's. +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] +wayland-client = "0.31" + +[lints] +workspace = true diff --git a/crates/window/README.md b/crates/window/README.md new file mode 100644 index 00000000..75b4c681 --- /dev/null +++ b/crates/window/README.md @@ -0,0 +1,70 @@ +craft-window +============ + +Decide who draws the window frame — the desktop or the app — so every Craft app shows the same +titlebar and borders on a given desktop. + +## Why + +egui apps either keep the desktop's window frame (titlebar, borders, window buttons) or turn it off +and draw their own. Across the suite this had drifted: + +- some apps always drew their own frame on Linux (so KDE's titlebar was replaced by the app's), +- some always kept the desktop's, +- some probed the compositor and only drew their own on GNOME. + +So two Craft apps open side by side on KDE could wear different titlebars. This crate makes the +decision in one place, matching each desktop. + +## The rule + +| Platform | Frame | +| --- | --- | +| macOS, Windows | desktop (the OS) | +| Linux/BSD on X11 | desktop (the window manager) | +| Linux/BSD on Wayland **with** `zxdg_decoration_manager_v1` (KDE, Sway, Hyprland, COSMIC) | desktop (the compositor) | +| Linux/BSD on Wayland **without** it (GNOME's Mutter) | the app | + +When the app draws the frame, the decision carries the window-button `Layout` the desktop names +(GNOME's `button-layout`), so the app's header bar puts minimize/maximize/close where that desktop +puts them. This crate decides *whether* and *where*; drawing the buttons stays in each app, whose +title bars differ. + +### Override + +`_TITLEBAR` forces the choice. With the default prefix `CRAFT`: `CRAFT_TITLEBAR=app` forces +the app frame, `=system` (or `=desktop`) forces the desktop frame. Apps pass their own prefix +(`Prefix::new("PDFCRAFT")` → `PDFCRAFT_TITLEBAR`) so each keeps its own variable name. + +## Use + +```rust,ignore +use craft_window::{Choice, Prefix, decorate}; + +// Once at startup, before the window is created: +let choice = Choice::probe(Prefix::new("PHOTOCRAFT")); +let viewport = decorate(egui::ViewportBuilder::default().with_title("PhotoCraft"), choice.frame()); + +// If the app draws the frame, lay the header-bar buttons out by the desktop's order: +if let Some(layout) = choice.frame().layout() { + // layout.left / layout.right -> draw Minimize/Maximize/Close there +} +``` + +An app that offers a "System title bar" preference checks `choice.overridden()` and its own setting, +and can override the probe by setting the frame to `Frame::Desktop`. + +The `egui` feature adds [`decorate`]. Without it, the crate is pure decision logic (`Choice`, +`Frame`, `Layout`) with no egui dependency. + +## Notes + +- The Wayland probe opens a short-lived connection and reads the global registry once, on its own + thread with a 500 ms timeout; on failure or timeout it keeps the desktop's frame. Call it once at + startup. +- It never touches the connection winit inherits: when `WAYLAND_SOCKET` is set (a compositor handed + the app an open descriptor), the probe skips connecting and keeps the desktop frame, so it can't + consume the socket winit needs. +- The GNOME `button-layout` lookup (`gsettings`) runs behind the same bounded worker, so a stalled + `gsettings` can't block startup either. +- `#![forbid(unsafe_code)]`, and the never-crash lints the workspace requires. diff --git a/crates/window/src/lib.rs b/crates/window/src/lib.rs new file mode 100644 index 00000000..42f936c5 --- /dev/null +++ b/crates/window/src/lib.rs @@ -0,0 +1,439 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +//! Decide who draws the window frame, so every Craft app shows the same titlebar and borders on a +//! given desktop. +//! +//! egui apps either let the desktop draw the window frame (the title bar, borders and window +//! buttons) or turn it off and draw their own. Across the suite this decision had drifted: some +//! apps always drew their own frame on Linux, some always kept the desktop's, and some probed the +//! compositor — so on KDE, say, one app wore KWin's title bar while another wore its own. This +//! crate makes the decision one place. +//! +//! The rule, by platform: +//! +//! - **macOS and Windows:** the OS always draws the frame ([`Frame::Desktop`]). (An app that insets +//! its content under the traffic lights, or draws its own caption buttons on Windows, still asks +//! the OS for a decorated window and overdraws it; that is the app's choice and not this crate's.) +//! - **Linux and the BSDs, X11:** the window manager draws the frame ([`Frame::Desktop`]). +//! - **Linux and the BSDs, Wayland:** a compositor that offers server-side decorations +//! (`zxdg_decoration_manager_v1` — KDE, Sway, Hyprland, COSMIC) draws the frame +//! ([`Frame::Desktop`]). One that does not (GNOME's Mutter) leaves the frame to the app +//! ([`Frame::App`]): without this, winit draws an imitation frame that matches no desktop. +//! +//! An environment variable overrides the choice, for users whose compositor the probe can't read +//! or who simply prefer the other frame. With the default prefix `CRAFT`, `CRAFT_TITLEBAR=app` +//! forces [`Frame::App`] and `=system` (or `=desktop`) forces [`Frame::Desktop`]; apps may pass +//! their own prefix (`PDFCRAFT`, `FIGCRAFT`, …) so each keeps its own variable name. +//! +//! When the app draws the frame, [`Frame::App`] carries the [`Layout`] of window buttons the +//! desktop names (GNOME's `button-layout`), so the app's header bar puts minimize, maximize and +//! close where that desktop puts them. This crate decides *whether* and *where*; drawing the +//! buttons stays with each app (their title bars differ). +//! +//! # With egui +//! +//! The `egui` feature adds [`decorate`], which applies the decision to an +//! [`egui::ViewportBuilder`] before the window is created: +//! +//! ```ignore +//! let viewport = egui::ViewportBuilder::default().with_title("PhotoCraft"); +//! // The app may still prefer its own frame when the user asked for it in preferences: +//! let choice = craft_window::Choice::probe(craft_window::Prefix::default()); +//! let viewport = craft_window::decorate(viewport, choice.frame()); +//! ``` +//! +//! The probe opens a short-lived Wayland connection and reads the global registry once; it never +//! touches the connection winit inherits. Call it once at startup. + +/// The environment-variable prefix an app overrides the frame with: `_TITLEBAR`. +/// +/// `Prefix::default()` is `CRAFT`, so `CRAFT_TITLEBAR`. An app that wants its own name passes +/// [`Prefix::new`] (`Prefix::new("PDFCRAFT")` reads `PDFCRAFT_TITLEBAR`). The name is used as +/// given; it should be ASCII letters, digits and underscores, as environment names are. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Prefix(&'static str); + +impl Default for Prefix { + fn default() -> Self { + Self("CRAFT") + } +} + +impl Prefix { + /// An app-specific prefix, so the override variable is `_TITLEBAR`. + pub const fn new(prefix: &'static str) -> Self { + Self(prefix) + } + + /// The environment variable this prefix reads, e.g. `CRAFT_TITLEBAR`. + pub fn var_name(self) -> String { + format!("{}_TITLEBAR", self.0) + } +} + +/// Who draws the window frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Frame { + /// The desktop draws it: the OS on macOS and Windows, the window manager on X11, the + /// compositor on a Wayland session with server-side decorations. The app leaves decorations on. + Desktop, + /// The app draws it (GNOME's Wayland session, or an override). The app turns decorations off + /// and draws a header bar with the window buttons in this [`Layout`]. + App(Layout), +} + +impl Frame { + /// Whether the app should keep the OS/compositor decorations on (`true` for [`Frame::Desktop`]). + /// This is the value to pass to `egui::ViewportBuilder::with_decorations`. + pub fn decorations(&self) -> bool { + matches!(self, Frame::Desktop) + } + + /// The window-button layout when the app draws the frame, else `None`. + pub fn layout(&self) -> Option<&Layout> { + match self { + Frame::App(layout) => Some(layout), + Frame::Desktop => None, + } + } +} + +/// How the frame was decided, so an app can log it or offer a "System title bar" preference that +/// knows the current state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Choice { + frame: Frame, + /// `Some(true)` when `_TITLEBAR` forced the app frame, `Some(false)` when it forced the + /// desktop frame, `None` when the platform decided. + overridden: Option, +} + +impl Choice { + /// Probe the running platform and desktop and decide who draws the frame. + /// + /// `prefix` names the override variable (`_TITLEBAR`). On macOS, Windows and X11 this + /// does no I/O; on a Wayland session it opens a short-lived connection to read the registry. + pub fn probe(prefix: Prefix) -> Self { + match override_frame(&prefix.var_name()) { + Some(true) => Self { frame: Frame::App(Layout::from_desktop()), overridden: Some(true) }, + Some(false) => Self { frame: Frame::Desktop, overridden: Some(false) }, + None if app_draws_frame() => Self { frame: Frame::App(Layout::from_desktop()), overridden: None }, + None => Self { frame: Frame::Desktop, overridden: None }, + } + } + + /// The decided frame. + pub fn frame(&self) -> &Frame { + &self.frame + } + + /// The decided frame, consumed. + pub fn into_frame(self) -> Frame { + self.frame + } + + /// Whether `_TITLEBAR` set the frame: `Some(true)` it forced the app's, `Some(false)` + /// the desktop's, `None` the platform decided. + pub fn overridden(&self) -> Option { + self.overridden + } +} + +/// Read ``: `app` → force the app frame, `system`/`desktop` → force the desktop frame, +/// anything else (or unset) → let the platform decide. +fn override_frame(var: &str) -> Option { + parse_override(std::env::var(var).ok().as_deref()) +} + +/// The override a `_TITLEBAR` value asks for: `Some(true)` the app frame, `Some(false)` the +/// desktop frame, `None` to let the platform decide. Pure, so it is tested without the environment. +fn parse_override(value: Option<&str>) -> Option { + match value.map(str::trim) { + Some("app") => Some(true), + Some("system" | "desktop") => Some(false), + _ => None, + } +} + +/// A window button. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Button { + Minimize, + Maximize, + Close, +} + +/// Which window buttons go on which end of the app-drawn header bar, outermost last. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Layout { + pub left: Vec