Skip to main content

frus_widgets/
locale.rs

1//! **Which language the interface is in** (`locale.dart`, `app.dart:146`).
2//!
3//! Two halves that were both missing, and missing in a way that is easy not to notice.
4//!
5//! Milestone 449 gave the framework a [`Localizations`](crate::localizations)
6//! table, so it could say *Retour* instead of *Back*. `frus-l10n` has had Fluent and
7//! its negotiation since long before that, so an application could translate its own
8//! messages. **Neither was ever told what language the device is set to.** The platform
9//! knows, every one of them reports it, and nothing here read it — so an application
10//! either hard-coded a language or asked the reader to pick one, on a device that already
11//! knew the answer.
12//!
13//! This module is the missing wire: a [`Locale`], the reference's resolution between what
14//! the reader prefers and what the application supports
15//! ([`resolve`](crate::locale::resolve)), and an ambient scope so any widget can ask
16//! [`of`](crate::locale::of) which one won.
17//!
18//! # It always answers
19//!
20//! [`of`](crate::locale::of) hands back `en` with nothing installed, the way
21//! [`localizations::of`](crate::localizations::of) hands back English — and it carries the
22//! same warning. A default that always works is what makes the feature safe to add and
23//! what would hide the shell forgetting to install one. The guard is not the default; it
24//! is a test that drives the shell.
25
26use std::cell::RefCell;
27use std::fmt;
28
29/// **A language, and optionally the script and the country it is written in**
30/// (`locale.dart`).
31///
32/// The language is required and the other two are not, which is the reference's shape and
33/// the useful one: `fr` is an answer, `fr-CA` is a better one, and `zh-Hant-TW` needs all
34/// three to be right.
35///
36/// Comparison is exact — `fr` is not `fr-CA`. Deciding that one of them will do for the
37/// other is [`resolve`]'s work, and it takes the whole list to do it.
38#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
39pub struct Locale {
40    language: String,
41    script: Option<String>,
42    country: Option<String>,
43}
44
45impl Locale {
46    /// A language on its own: `Locale::new("fr")`.
47    pub fn new(language: impl Into<String>) -> Locale {
48        Locale {
49            language: language.into().to_ascii_lowercase(),
50            script: None,
51            country: None,
52        }
53    }
54
55    /// A language in a country: `Locale::with_country("fr", "CA")`.
56    pub fn with_country(language: impl Into<String>, country: impl Into<String>) -> Locale {
57        Locale::new(language).country(country)
58    }
59
60    /// Adds a **script** (`Hant`, `Latn`): the writing system, which for some languages is
61    /// the question that decides legibility rather than idiom.
62    #[must_use]
63    pub fn script(mut self, script: impl Into<String>) -> Locale {
64        self.script = Some(normalise_script(&script.into()));
65        self
66    }
67
68    /// Adds a **country** (`CA`, `BR`).
69    #[must_use]
70    pub fn country(mut self, country: impl Into<String>) -> Locale {
71        self.country = Some(country.into().to_ascii_uppercase());
72        self
73    }
74
75    /// The language subtag, lowercase. Always present.
76    pub fn language_code(&self) -> &str {
77        &self.language
78    }
79
80    /// The script subtag, title case, if there is one.
81    pub fn script_code(&self) -> Option<&str> {
82        self.script.as_deref()
83    }
84
85    /// The country subtag, uppercase, if there is one.
86    pub fn country_code(&self) -> Option<&str> {
87        self.country.as_deref()
88    }
89
90    /// **Reads a language tag**: `fr`, `fr-CA`, `fr_CA`, `zh-Hant-TW`, `en-US.UTF-8`,
91    /// `pt_BR@euro`.
92    ///
93    /// Both separators, because both arrive: a language tag uses `-` and a POSIX
94    /// environment variable uses `_`. The encoding and modifier suffixes a Unix `LANG`
95    /// carries are dropped — `en_US.UTF-8` is the `en-US` locale in a particular byte
96    /// encoding, and the encoding is not part of which language this is.
97    ///
98    /// A subtag is read by **shape**, as BCP 47 does: four letters is a script, two
99    /// letters or three digits is a region. That is what lets `zh-Hant` and `zh-TW` both
100    /// be understood without a table of every code.
101    ///
102    /// Returns `None` for a tag with no language in it at all, including the `C` and
103    /// `POSIX` locales, which name an absence of one.
104    pub fn parse(tag: &str) -> Option<Locale> {
105        let tag = tag.split(['.', '@']).next().unwrap_or("");
106        let mut parts = tag.split(['-', '_']).filter(|part| !part.is_empty());
107        let language = parts.next()?.to_ascii_lowercase();
108        if !language.chars().all(|c| c.is_ascii_alphabetic())
109            || language.len() < 2
110            || language.len() > 3
111            || language == "c"
112            || language == "posix"
113        {
114            return None;
115        }
116        let mut locale = Locale::new(language);
117        for part in parts {
118            let alphabetic = part.chars().all(|c| c.is_ascii_alphabetic());
119            if part.len() == 4 && alphabetic && locale.script.is_none() {
120                locale = locale.script(part);
121            } else if locale.country.is_none()
122                && ((part.len() == 2 && alphabetic)
123                    || (part.len() == 3 && part.chars().all(|c| c.is_ascii_digit())))
124            {
125                locale = locale.country(part);
126            }
127            // Anything else is a variant or an extension, which nothing here reads yet.
128        }
129        Some(locale)
130    }
131}
132
133/// A script subtag is written `Hant`, not `HANT` or `hant`.
134fn normalise_script(script: &str) -> String {
135    let mut out = String::with_capacity(script.len());
136    for (i, c) in script.chars().enumerate() {
137        if i == 0 {
138            out.extend(c.to_uppercase());
139        } else {
140            out.extend(c.to_lowercase());
141        }
142    }
143    out
144}
145
146impl fmt::Display for Locale {
147    /// The **language tag**, hyphenated: `fr`, `fr-CA`, `zh-Hant-TW`. What
148    /// [`Locale::parse`] reads back.
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(&self.language)?;
151        if let Some(script) = &self.script {
152            write!(f, "-{script}")?;
153        }
154        if let Some(country) = &self.country {
155            write!(f, "-{country}")?;
156        }
157        Ok(())
158    }
159}
160
161impl std::str::FromStr for Locale {
162    type Err = ();
163
164    fn from_str(s: &str) -> Result<Locale, ()> {
165        Locale::parse(s).ok_or(())
166    }
167}
168
169impl Default for Locale {
170    /// `en`, which is what every string the framework says was written in before any of
171    /// them could be translated.
172    fn default() -> Locale {
173        Locale::new("en")
174    }
175}
176
177/// **Which of the application's languages the reader gets** — the reference's
178/// `basicLocaleListResolution` (`app.dart:146`), which is what its app widget runs.
179///
180/// `preferred` is the reader's list, best first, as the platform reports it. `supported`
181/// is what the application actually has. The answer is always one of `supported`, or its
182/// first entry when nothing matches — an interface in the wrong language beats no
183/// interface.
184///
185/// The order of attempts, per preferred locale in turn:
186///
187/// 1. an exact match on all three subtags;
188/// 2. language and script;
189/// 3. language and country;
190/// 4. language alone — **held back**, not returned at once, unless it came from the
191///    reader's very first choice and the next choice is a different language;
192/// 5. country alone, remembered but never preferred over a language match.
193///
194/// The fourth rung is the one worth understanding. A language-only match is a weak match:
195/// `fr` for a reader who asked for `fr-CA`. From the reader's **second** choice onward it
196/// is remembered rather than returned, and only used once the round after it has failed to
197/// do better — so a reader listing `[it, fr-BE, fr-CA]` gets `fr-CA` and not `fr`.
198///
199/// The reader's **first** choice is trusted instead: a weak match on it is taken at once,
200/// because being asked for French first outranks an exact match on a language asked for
201/// second. The one exception is a next choice in the same language, where waiting a round
202/// cannot lose anything and may find a country match.
203///
204/// It does **not** consider how close two languages are to each other. Neither does the
205/// reference's, which says so in the same words: German resolves to Chinese over French
206/// if Chinese is listed first.
207pub fn resolve(preferred: &[Locale], supported: &[Locale]) -> Locale {
208    let Some(first_supported) = supported.first() else {
209        return Locale::default();
210    };
211    // A platform that reports no locales, or one asked before it has had a chance to say:
212    // the application's own first choice is the honest answer.
213    if preferred.is_empty() {
214        return first_supported.clone();
215    }
216
217    // The supported list is indexed once rather than searched per preferred locale, and
218    // the **first** entry to claim a key keeps it: an application listing `en-US` before
219    // `en-GB` has said which English it would rather give a reader who only asked for
220    // `en`.
221    let find = |f: &dyn Fn(&Locale) -> bool| supported.iter().find(|l| f(l)).cloned();
222
223    let mut by_language: Option<Locale> = None;
224    let mut by_country: Option<Locale> = None;
225
226    for (index, wanted) in preferred.iter().enumerate() {
227        // 1. Everything matches.
228        if supported.contains(wanted) {
229            return wanted.clone();
230        }
231        // 2. The language and the script it is written in.
232        if let Some(script) = wanted.script_code() {
233            if let Some(found) = find(&|l| {
234                l.language_code() == wanted.language_code() && l.script_code() == Some(script)
235            }) {
236                return found;
237            }
238        }
239        // 3. The language and the country.
240        if let Some(country) = wanted.country_code() {
241            if let Some(found) = find(&|l| {
242                l.language_code() == wanted.language_code() && l.country_code() == Some(country)
243            }) {
244                return found;
245            }
246        }
247        // 4. A language-only match held back from the previous round, now that this
248        //    round has failed to do better than it.
249        if let Some(found) = by_language.take() {
250            return found;
251        }
252        if let Some(found) = find(&|l| l.language_code() == wanted.language_code()) {
253            let next_is_same_language = preferred
254                .get(index + 1)
255                .is_some_and(|next| next.language_code() == wanted.language_code());
256            // The reader's first choice is strongly preferred, so a language match on it
257            // is taken at once — unless the next choice is the same language, where
258            // waiting one round costs nothing and may find a country match.
259            if index == 0 && !next_is_same_language {
260                return found;
261            }
262            by_language = Some(found);
263        }
264        // 5. The country alone. A reader is likely to know a language spoken where they
265        //    are, so it beats the application's default — but never a language match.
266        if by_country.is_none() {
267            if let Some(country) = wanted.country_code() {
268                by_country = find(&|l| l.country_code() == Some(country));
269            }
270        }
271    }
272
273    by_language
274        .or(by_country)
275        .unwrap_or_else(|| first_supported.clone())
276}
277
278thread_local! {
279    /// The locale in force for this thread. `None` until something installs one.
280    static AMBIENT: RefCell<Option<Locale>> = const { RefCell::new(None) };
281}
282
283/// **The locale in force**, or [`Locale::default`] (`en`) when nothing has been installed.
284///
285/// This is the *resolved* one — one of the application's supported locales — not the
286/// reader's raw first preference. A widget asking what language to write in wants the
287/// language it will actually find words in.
288pub fn of() -> Locale {
289    AMBIENT.with(|ambient| ambient.borrow().clone().unwrap_or_default())
290}
291
292/// Installs `locale` for this thread, from now on. The shell does this every frame.
293pub fn install(locale: Locale) {
294    AMBIENT.with(|ambient| *ambient.borrow_mut() = Some(locale));
295}
296
297/// Runs `f` with `locale` in force, and puts back whatever was there before — including
298/// when `f` panics, so one bad frame cannot leave a stale language installed for every
299/// frame after it.
300pub fn scope<R>(locale: Locale, f: impl FnOnce() -> R) -> R {
301    let previous = AMBIENT.with(|ambient| ambient.borrow_mut().replace(locale));
302    let _restore = Restore(previous);
303    f()
304}
305
306/// Puts back the previous locale when dropped, panic or not.
307struct Restore(Option<Locale>);
308
309impl Drop for Restore {
310    fn drop(&mut self) {
311        let previous = self.0.take();
312        AMBIENT.with(|ambient| *ambient.borrow_mut() = previous);
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn locales(tags: &[&str]) -> Vec<Locale> {
321        tags.iter().filter_map(|tag| Locale::parse(tag)).collect()
322    }
323
324    /// **A tag is read by the shape of its subtags**, which is what BCP 47 says and what
325    /// makes `zh-Hant` and `zh-TW` both readable without a table of every code.
326    ///
327    /// Both separators, because both arrive: a language tag uses `-`, a `LANG`
328    /// environment variable uses `_` and carries an encoding after a dot.
329    #[test]
330    fn a_language_tag_is_read_by_the_shape_of_its_subtags() {
331        assert_eq!(Locale::parse("fr").unwrap().to_string(), "fr");
332        assert_eq!(Locale::parse("fr-CA").unwrap().to_string(), "fr-CA");
333        assert_eq!(Locale::parse("fr_ca").unwrap().to_string(), "fr-CA");
334        assert_eq!(
335            Locale::parse("zh-Hant-TW").unwrap().to_string(),
336            "zh-Hant-TW"
337        );
338        assert_eq!(Locale::parse("zh-hant").unwrap().to_string(), "zh-Hant");
339        assert_eq!(
340            Locale::parse("en_US.UTF-8").unwrap(),
341            Locale::with_country("en", "US"),
342            "the encoding is not part of which language this is"
343        );
344        assert_eq!(
345            Locale::parse("pt_BR@euro").unwrap(),
346            Locale::with_country("pt", "BR")
347        );
348        assert_eq!(
349            Locale::parse("es-419").unwrap().country_code(),
350            Some("419"),
351            "a three-digit region is a region"
352        );
353
354        // And the tags that name no language at all.
355        assert!(Locale::parse("C").is_none());
356        assert!(Locale::parse("POSIX").is_none());
357        assert!(Locale::parse("").is_none());
358        assert!(Locale::parse("123").is_none());
359    }
360
361    /// **Comparison is exact.** `fr` is not `fr-CA`, and deciding that one will do for the
362    /// other is [`resolve`]'s work — with the whole list in hand, which is the only way to
363    /// do it well.
364    #[test]
365    fn two_locales_are_equal_or_they_are_not() {
366        assert_eq!(Locale::new("FR"), Locale::new("fr"));
367        assert_ne!(Locale::new("fr"), Locale::with_country("fr", "CA"));
368        assert_eq!(
369            Locale::with_country("fr", "ca"),
370            Locale::with_country("fr", "CA")
371        );
372    }
373
374    /// **The reference's resolution, rung by rung** (`app.dart:146`).
375    #[test]
376    fn the_rungs_of_a_locale_resolution() {
377        let supported = locales(&["en", "en-GB", "fr", "fr-CA", "zh-Hant", "es-419"]);
378
379        assert_eq!(
380            resolve(&locales(&["fr-CA"]), &supported),
381            Locale::with_country("fr", "CA"),
382            "an exact match"
383        );
384        assert_eq!(
385            resolve(&locales(&["zh-Hant-TW"]), &supported),
386            Locale::parse("zh-Hant").unwrap(),
387            "language and script, where the country is not on offer"
388        );
389        assert_eq!(
390            resolve(&locales(&["en-GB"]), &supported),
391            Locale::with_country("en", "GB"),
392            "language and country"
393        );
394        assert_eq!(
395            resolve(&locales(&["fr-FR"]), &supported),
396            Locale::new("fr"),
397            "the language alone, from the reader's first choice"
398        );
399        assert_eq!(
400            resolve(&locales(&["de"]), &supported),
401            Locale::new("en"),
402            "and the application's first, when nothing matches at all"
403        );
404        assert_eq!(
405            resolve(&[], &supported),
406            Locale::new("en"),
407            "as when the platform reports nothing"
408        );
409    }
410
411    /// **A language-only match waits one round — from the second choice onward.**
412    ///
413    /// It is a weak match: `fr` for a reader who asked for `fr-CA`. Held back, it lets the
414    /// next preference show a better one; if none does, it stands.
415    ///
416    /// The reader's **first** choice is trusted rather than held back, and that is not an
417    /// optimisation — it is the answer. A reader whose list begins with `fr-CA` gets
418    /// French even where the application matches their *second* choice exactly, because
419    /// asking for French first outranks asking for Canadian English second. The exception
420    /// is a next choice in the same language, where waiting a round can only improve
421    /// things.
422    #[test]
423    fn a_weak_language_match_waits_for_a_better_one() {
424        let supported = locales(&["en", "fr", "fr-CA"]);
425
426        // Second choice: held back, and the third does better.
427        assert_eq!(
428            resolve(&locales(&["it", "fr-BE", "fr-CA"]), &supported),
429            Locale::with_country("fr", "CA"),
430            "the round after it found the country, so the weak match gave way"
431        );
432
433        // Held back, and nothing better turned up: it stands.
434        assert_eq!(
435            resolve(&locales(&["it", "fr-BE", "de"]), &supported),
436            Locale::new("fr")
437        );
438
439        // First choice: taken at once, even though the second choice matches exactly.
440        assert_eq!(
441            resolve(&locales(&["fr-CH", "en"]), &locales(&["en", "fr", "de"])),
442            Locale::new("fr"),
443            "asked for French first, and French is here"
444        );
445
446        // Unless the next choice is the same language, where waiting finds the country.
447        assert_eq!(
448            resolve(&locales(&["fr-BE", "fr-CA"]), &supported),
449            Locale::with_country("fr", "CA")
450        );
451    }
452
453    /// **A country beats the application's default and loses to any language match**
454    /// (`app.dart:227`): a reader is likely to know a language spoken where they are, and
455    /// that is still a guess about them rather than a statement by them.
456    #[test]
457    fn a_country_is_the_last_thing_tried() {
458        let supported = locales(&["en", "fr-CA"]);
459        assert_eq!(
460            resolve(&locales(&["it-CA"]), &supported),
461            Locale::with_country("fr", "CA"),
462            "no Italian, but Canada is on the list"
463        );
464        assert_eq!(
465            resolve(&locales(&["it-CA", "en-AU"]), &supported),
466            Locale::new("en"),
467            "and a language match outranks it, however late it arrives"
468        );
469    }
470
471    /// **The application's order decides ties**: listing `en-US` before `en-GB` says which
472    /// English a reader who only asked for `en` should get.
473    #[test]
474    fn the_application_s_order_decides_which_of_two_will_do() {
475        let supported = locales(&["en-US", "en-GB"]);
476        assert_eq!(
477            resolve(&locales(&["en"]), &supported),
478            Locale::with_country("en", "US")
479        );
480        let supported = locales(&["en-GB", "en-US"]);
481        assert_eq!(
482            resolve(&locales(&["en"]), &supported),
483            Locale::with_country("en", "GB")
484        );
485    }
486
487    /// **The ambient scope answers, and puts back what it found** — including through a
488    /// panic, so one bad frame cannot leave the wrong language installed for every frame
489    /// after it.
490    #[test]
491    fn the_scope_answers_and_restores() {
492        assert_eq!(of(), Locale::new("en"));
493        scope(Locale::with_country("fr", "CA"), || {
494            assert_eq!(of(), Locale::with_country("fr", "CA"));
495            scope(Locale::new("de"), || assert_eq!(of(), Locale::new("de")));
496            assert_eq!(of(), Locale::with_country("fr", "CA"), "and nests");
497        });
498        assert_eq!(of(), Locale::new("en"));
499
500        let caught = std::panic::catch_unwind(|| {
501            scope(Locale::new("de"), || panic!("mid-frame"));
502        });
503        assert!(caught.is_err());
504        assert_eq!(of(), Locale::new("en"), "a panic put the previous one back");
505    }
506}