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`] 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.
288#[inline(never)]
289pub fn of() -> Locale {
290 AMBIENT.with(|ambient| ambient.borrow().clone().unwrap_or_default())
291}
292
293/// Installs `locale` for this thread, from now on. The shell does this every frame.
294#[inline(never)]
295pub fn install(locale: Locale) {
296 AMBIENT.with(|ambient| *ambient.borrow_mut() = Some(locale));
297}
298
299/// Runs `f` with `locale` in force, and puts back whatever was there before — including
300/// when `f` panics, so one bad frame cannot leave a stale language installed for every
301/// frame after it.
302pub fn scope<R>(locale: Locale, f: impl FnOnce() -> R) -> R {
303 let _restore = Restore(swap(Some(locale)));
304 f()
305}
306
307/// Puts `next` in place, and gives back what was there. Not generic, and not inlined: see
308/// `component.rs` on thread-locals and hot patches.
309#[inline(never)]
310fn swap(next: Option<Locale>) -> Option<Locale> {
311 AMBIENT.with(|ambient| std::mem::replace(&mut *ambient.borrow_mut(), next))
312}
313
314/// Puts back the previous locale when dropped, panic or not.
315struct Restore(Option<Locale>);
316
317impl Drop for Restore {
318 fn drop(&mut self) {
319 swap(self.0.take());
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 fn locales(tags: &[&str]) -> Vec<Locale> {
328 tags.iter().filter_map(|tag| Locale::parse(tag)).collect()
329 }
330
331 /// **A tag is read by the shape of its subtags**, which is what BCP 47 says and what
332 /// makes `zh-Hant` and `zh-TW` both readable without a table of every code.
333 ///
334 /// Both separators, because both arrive: a language tag uses `-`, a `LANG`
335 /// environment variable uses `_` and carries an encoding after a dot.
336 #[test]
337 fn a_language_tag_is_read_by_the_shape_of_its_subtags() {
338 assert_eq!(Locale::parse("fr").unwrap().to_string(), "fr");
339 assert_eq!(Locale::parse("fr-CA").unwrap().to_string(), "fr-CA");
340 assert_eq!(Locale::parse("fr_ca").unwrap().to_string(), "fr-CA");
341 assert_eq!(
342 Locale::parse("zh-Hant-TW").unwrap().to_string(),
343 "zh-Hant-TW"
344 );
345 assert_eq!(Locale::parse("zh-hant").unwrap().to_string(), "zh-Hant");
346 assert_eq!(
347 Locale::parse("en_US.UTF-8").unwrap(),
348 Locale::with_country("en", "US"),
349 "the encoding is not part of which language this is"
350 );
351 assert_eq!(
352 Locale::parse("pt_BR@euro").unwrap(),
353 Locale::with_country("pt", "BR")
354 );
355 assert_eq!(
356 Locale::parse("es-419").unwrap().country_code(),
357 Some("419"),
358 "a three-digit region is a region"
359 );
360
361 // And the tags that name no language at all.
362 assert!(Locale::parse("C").is_none());
363 assert!(Locale::parse("POSIX").is_none());
364 assert!(Locale::parse("").is_none());
365 assert!(Locale::parse("123").is_none());
366 }
367
368 /// **Comparison is exact.** `fr` is not `fr-CA`, and deciding that one will do for the
369 /// other is [`resolve`]'s work — with the whole list in hand, which is the only way to
370 /// do it well.
371 #[test]
372 fn two_locales_are_equal_or_they_are_not() {
373 assert_eq!(Locale::new("FR"), Locale::new("fr"));
374 assert_ne!(Locale::new("fr"), Locale::with_country("fr", "CA"));
375 assert_eq!(
376 Locale::with_country("fr", "ca"),
377 Locale::with_country("fr", "CA")
378 );
379 }
380
381 /// **The reference's resolution, rung by rung** (`app.dart:146`).
382 #[test]
383 fn the_rungs_of_a_locale_resolution() {
384 let supported = locales(&["en", "en-GB", "fr", "fr-CA", "zh-Hant", "es-419"]);
385
386 assert_eq!(
387 resolve(&locales(&["fr-CA"]), &supported),
388 Locale::with_country("fr", "CA"),
389 "an exact match"
390 );
391 assert_eq!(
392 resolve(&locales(&["zh-Hant-TW"]), &supported),
393 Locale::parse("zh-Hant").unwrap(),
394 "language and script, where the country is not on offer"
395 );
396 assert_eq!(
397 resolve(&locales(&["en-GB"]), &supported),
398 Locale::with_country("en", "GB"),
399 "language and country"
400 );
401 assert_eq!(
402 resolve(&locales(&["fr-FR"]), &supported),
403 Locale::new("fr"),
404 "the language alone, from the reader's first choice"
405 );
406 assert_eq!(
407 resolve(&locales(&["de"]), &supported),
408 Locale::new("en"),
409 "and the application's first, when nothing matches at all"
410 );
411 assert_eq!(
412 resolve(&[], &supported),
413 Locale::new("en"),
414 "as when the platform reports nothing"
415 );
416 }
417
418 /// **A language-only match waits one round — from the second choice onward.**
419 ///
420 /// It is a weak match: `fr` for a reader who asked for `fr-CA`. Held back, it lets the
421 /// next preference show a better one; if none does, it stands.
422 ///
423 /// The reader's **first** choice is trusted rather than held back, and that is not an
424 /// optimisation — it is the answer. A reader whose list begins with `fr-CA` gets
425 /// French even where the application matches their *second* choice exactly, because
426 /// asking for French first outranks asking for Canadian English second. The exception
427 /// is a next choice in the same language, where waiting a round can only improve
428 /// things.
429 #[test]
430 fn a_weak_language_match_waits_for_a_better_one() {
431 let supported = locales(&["en", "fr", "fr-CA"]);
432
433 // Second choice: held back, and the third does better.
434 assert_eq!(
435 resolve(&locales(&["it", "fr-BE", "fr-CA"]), &supported),
436 Locale::with_country("fr", "CA"),
437 "the round after it found the country, so the weak match gave way"
438 );
439
440 // Held back, and nothing better turned up: it stands.
441 assert_eq!(
442 resolve(&locales(&["it", "fr-BE", "de"]), &supported),
443 Locale::new("fr")
444 );
445
446 // First choice: taken at once, even though the second choice matches exactly.
447 assert_eq!(
448 resolve(&locales(&["fr-CH", "en"]), &locales(&["en", "fr", "de"])),
449 Locale::new("fr"),
450 "asked for French first, and French is here"
451 );
452
453 // Unless the next choice is the same language, where waiting finds the country.
454 assert_eq!(
455 resolve(&locales(&["fr-BE", "fr-CA"]), &supported),
456 Locale::with_country("fr", "CA")
457 );
458 }
459
460 /// **A country beats the application's default and loses to any language match**
461 /// (`app.dart:227`): a reader is likely to know a language spoken where they are, and
462 /// that is still a guess about them rather than a statement by them.
463 #[test]
464 fn a_country_is_the_last_thing_tried() {
465 let supported = locales(&["en", "fr-CA"]);
466 assert_eq!(
467 resolve(&locales(&["it-CA"]), &supported),
468 Locale::with_country("fr", "CA"),
469 "no Italian, but Canada is on the list"
470 );
471 assert_eq!(
472 resolve(&locales(&["it-CA", "en-AU"]), &supported),
473 Locale::new("en"),
474 "and a language match outranks it, however late it arrives"
475 );
476 }
477
478 /// **The application's order decides ties**: listing `en-US` before `en-GB` says which
479 /// English a reader who only asked for `en` should get.
480 #[test]
481 fn the_application_s_order_decides_which_of_two_will_do() {
482 let supported = locales(&["en-US", "en-GB"]);
483 assert_eq!(
484 resolve(&locales(&["en"]), &supported),
485 Locale::with_country("en", "US")
486 );
487 let supported = locales(&["en-GB", "en-US"]);
488 assert_eq!(
489 resolve(&locales(&["en"]), &supported),
490 Locale::with_country("en", "GB")
491 );
492 }
493
494 /// **The ambient scope answers, and puts back what it found** — including through a
495 /// panic, so one bad frame cannot leave the wrong language installed for every frame
496 /// after it.
497 #[test]
498 fn the_scope_answers_and_restores() {
499 assert_eq!(of(), Locale::new("en"));
500 scope(Locale::with_country("fr", "CA"), || {
501 assert_eq!(of(), Locale::with_country("fr", "CA"));
502 scope(Locale::new("de"), || assert_eq!(of(), Locale::new("de")));
503 assert_eq!(of(), Locale::with_country("fr", "CA"), "and nests");
504 });
505 assert_eq!(of(), Locale::new("en"));
506
507 let caught = std::panic::catch_unwind(|| {
508 scope(Locale::new("de"), || panic!("mid-frame"));
509 });
510 assert!(caught.is_err());
511 assert_eq!(of(), Locale::new("en"), "a panic put the previous one back");
512 }
513}