Skip to main content

frus_widgets/
localizations.rs

1//! **The words the framework itself puts on screen**, in the reader's language
2//! (`material_localizations.dart`).
3//!
4//! A framework says a certain amount out loud on an application's behalf. The label a
5//! screen reader announces on a back arrow. The word on the cross that dismisses a
6//! notification. The initials over a calendar's columns, and the name of the month above
7//! them. None of it comes from the application, so none of it can be translated by the
8//! application — and until this module every one of them was an English string constant.
9//!
10//! The reference's answer is `MaterialLocalizations`, reached from the tree. This is the
11//! same idea with this framework's ambient-scope idiom (the one [`MediaQuery`] uses): a
12//! table is installed for the thread, and [`of`](crate::localizations::of) hands out whatever is in force.
13//!
14//! # It always answers
15//!
16//! [`of`](crate::localizations::of) never fails. With nothing installed it answers [`English`], which is what every
17//! one of those constants said anyway — so nothing breaks, and an application that says
18//! nothing is exactly where it was.
19//!
20//! That has a cost worth naming: a test that forgets to install a table still passes, and
21//! so would a shell that forgot to install one. The guard against that is not the default
22//! but `a_shell_installs_the_application_s_words`, which drives the shell and checks the
23//! words actually arrive.
24//!
25//! # It is not on the theme
26//!
27//! A theme is what an interface **looks like**. What it says is a different question with
28//! a different owner — the reference keeps them apart too, and an application that ships
29//! one theme in twelve languages would otherwise need twelve themes.
30//!
31//! [`MediaQuery`]: crate::MediaQuery
32
33use std::cell::RefCell;
34use std::rc::Rc;
35
36/// Writes `number` with its thousands separated by `separator`, from the right.
37///
38/// Shared by the tables rather than written twice: what changes from one language to the
39/// next is the character, not the arithmetic.
40fn group_with(number: usize, separator: &str) -> String {
41    let digits = number.to_string();
42    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
43    for (seen, ch) in digits.chars().enumerate() {
44        if seen > 0 && (digits.len() - seen).is_multiple_of(3) {
45            out.push_str(separator);
46        }
47        out.push(ch);
48    }
49    out
50}
51
52/// **What the framework says**, for one language.
53///
54/// Every method has an English body, so implementing this means writing down only what
55/// differs. The reference's table has around a hundred entries; this has the ones the
56/// framework actually says today, and grows as it says more.
57///
58/// **A default is not agreement.** An entry left unanswered is an English string in a
59/// French interface, and it compiles. So a table shipped here writes out every entry, even
60/// the ones whose right answer in that language *is* the English one — "OK" is "OK" in a
61/// good many languages — with the reason beside it; [`French`] does, and a test reads this
62/// file to check that it still does.
63pub trait Localizations {
64    /// What a reader hears on a back arrow (`backButtonTooltip`).
65    fn back_button_label(&self) -> &str {
66        "Back"
67    }
68
69    /// And on the cross that dismisses something (`closeButtonTooltip`).
70    fn close_button_label(&self) -> &str {
71        "Close"
72    }
73
74    /// What a reader hears on the control that opens a side panel
75    /// (`openAppDrawerTooltip`).
76    ///
77    /// **One word for both edges.** The reference says the same thing for a leading
78    /// panel and a trailing one (`action_buttons.dart:331` against `:362`): a reader told
79    /// which edge a panel comes in from is being told about the layout rather than about
80    /// the action.
81    fn open_drawer_label(&self) -> &str {
82        "Open navigation menu"
83    }
84
85    /// **Where one destination sits among the rest** (`tabLabel`), for a reader who is
86    /// hearing them one at a time and cannot see how many there are.
87    ///
88    /// `index` counts from one, as it reads: "Tab 1 of 3". It is the one entry here that
89    /// takes arguments and so returns an owned string rather than a borrowed one — the
90    /// numbers are the caller's, and a table cannot have written the sentence in advance.
91    fn tab_label(&self, index: usize, count: usize) -> String {
92        format!("Tab {index} of {count}")
93    }
94
95    /// **What a reader hears on an account header** (`signedInLabel`): the name, the
96    /// address and the control for switching arrive as one thing with one name, rather
97    /// than as three unrelated nodes at the top of a panel.
98    fn signed_in_label(&self) -> &str {
99        "Signed in"
100    }
101
102    /// And on the control that reveals the other accounts (`showAccountsLabel`).
103    fn show_accounts_label(&self) -> &str {
104        "Show accounts"
105    }
106
107    /// And on the same control once they are showing (`hideAccountsLabel`).
108    ///
109    /// **Two entries rather than one that flips**, as the reference has it: a control
110    /// named for what it *will do* is the only kind a reader can act on, and the two
111    /// sentences are not each other's negation in every language.
112    fn hide_accounts_label(&self) -> &str {
113        "Hide accounts"
114    }
115
116    /// **What a reader hears on the cross that empties a field** (`clearButtonTooltip`).
117    ///
118    /// Not [`close_button_label`](Self::close_button_label): the same glyph means two
119    /// different things depending on what it sits in, and *Close* on a control that empties
120    /// a search box would send a reader looking for the thing it closed.
121    fn clear_button_label(&self) -> &str {
122        "Clear"
123    }
124
125    /// The word on a confirming button (`okButtonLabel`).
126    fn ok_button_label(&self) -> &str {
127        "OK"
128    }
129
130    /// And on the one that backs out (`cancelButtonLabel`).
131    fn cancel_button_label(&self) -> &str {
132        "Cancel"
133    }
134
135    /// **The placeholder in a table's search field** (`searchFieldLabel`).
136    fn search_field_label(&self) -> &str {
137        "Search"
138    }
139
140    /// **What a table says when it has nothing to show.** A caller may say its own; this
141    /// is what one that says nothing gets, and it used to be a constant.
142    fn no_results_label(&self) -> &str {
143        "No results"
144    }
145
146    /// **What a reader hears on the control that steps a number down**, and on the one
147    /// that steps it up.
148    ///
149    /// The glyphs are a minus and a plus, which say nothing out loud.
150    fn decrease_label(&self) -> &str {
151        "Less"
152    }
153
154    /// See [`decrease_label`](Self::decrease_label).
155    fn increase_label(&self) -> &str {
156        "More"
157    }
158
159    /// **What a table announces when a row is ticked**, and the three sentences beside it
160    /// (`selectedRowCountTitle`).
161    ///
162    /// Four whole sentences rather than one built from parts, for the reason
163    /// [`hide_accounts_label`](Self::hide_accounts_label) is its own entry: a language
164    /// that agrees its participle with the thing selected cannot be served by a formula
165    /// that glues *row* to *selected*.
166    fn row_selected_label(&self) -> &str {
167        "Row selected"
168    }
169
170    /// See [`row_selected_label`](Self::row_selected_label).
171    fn row_deselected_label(&self) -> &str {
172        "Row deselected"
173    }
174
175    /// See [`row_selected_label`](Self::row_selected_label).
176    fn all_rows_selected_label(&self) -> &str {
177        "All rows selected"
178    }
179
180    /// See [`row_selected_label`](Self::row_selected_label).
181    fn all_rows_deselected_label(&self) -> &str {
182        "All rows deselected"
183    }
184
185    /// **The heading over a time picker's hours**, and the one over its minutes
186    /// (`timePickerHourLabel`, `timePickerMinuteLabel`).
187    fn hour_label(&self) -> &str {
188        "Hour"
189    }
190
191    /// See [`hour_label`](Self::hour_label).
192    fn minute_label(&self) -> &str {
193        "Minute"
194    }
195
196    /// **The two halves of a twelve-hour clock** (`anteMeridiemAbbreviation`,
197    /// `postMeridiemAbbreviation`).
198    ///
199    /// Left as the Latin abbreviations in a good many languages that never use a
200    /// twelve-hour clock at all — which is not an oversight in a table that says so, and
201    /// is why a test forbidding a French entry from matching the English one has to have
202    /// exceptions.
203    fn ante_meridiem_label(&self) -> &str {
204        "AM"
205    }
206
207    /// See [`ante_meridiem_label`](Self::ante_meridiem_label).
208    fn post_meridiem_label(&self) -> &str {
209        "PM"
210    }
211
212    /// **The two ends of a range**: the heading over the first picker of a pair, and over
213    /// the second.
214    fn start_label(&self) -> &str {
215        "Start"
216    }
217
218    /// See [`start_label`](Self::start_label).
219    fn end_label(&self) -> &str {
220        "End"
221    }
222
223    /// **The line under a paginated table**: which rows are showing, out of how many.
224    ///
225    /// `start` and `end` count from one and are inclusive, as the sentence reads. An empty
226    /// table says so rather than saying "1–0 of 0".
227    ///
228    /// A whole sentence rather than a separator and a word, because the pieces do not go in
229    /// the same order in every language, and because the numbers in it want grouping —
230    /// which is [`group_digits`](Self::group_digits), and is not the same character
231    /// everywhere.
232    fn page_range_label(&self, start: usize, end: usize, total: usize) -> String {
233        if total == 0 {
234            return "0 of 0".to_string();
235        }
236        format!(
237            "{}–{} of {}",
238            self.group_digits(start),
239            self.group_digits(end),
240            self.group_digits(total)
241        )
242    }
243
244    /// **The label beside a table's page-size chooser.**
245    fn rows_per_page_label(&self) -> &str {
246        "Rows per page"
247    }
248
249    /// **What a table's bulk-action bar says about how much is selected.**
250    ///
251    /// Takes the count because languages disagree about what a count does to the words
252    /// after it: English says "1 selected" and "3 selected", and French has to agree the
253    /// participle with the number.
254    fn selected_row_count_label(&self, count: usize) -> String {
255        format!("{} selected", self.group_digits(count))
256    }
257
258    /// **A number with its thousands grouped**, as the reader's language groups them: a
259    /// comma in English, a space in French, and a stop in a good many other places.
260    ///
261    /// Four thousand rows written `4000` is a number a reader has to count the digits of.
262    fn group_digits(&self, number: usize) -> String {
263        group_with(number, ",")
264    }
265
266    /// **What a reader hears on the arrows either side of a calendar's month.**
267    fn previous_month_label(&self) -> &str {
268        "Previous month"
269    }
270
271    /// See [`previous_month_label`](Self::previous_month_label).
272    fn next_month_label(&self) -> &str {
273        "Next month"
274    }
275
276    /// The single letters over a calendar's columns (`narrowWeekdays`).
277    ///
278    /// **Always Sunday first**, whatever day the week starts on where the reader is —
279    /// the reference is explicit about this (`date.dart:353`), and it is what lets
280    /// [`first_day_of_week_index`](Self::first_day_of_week_index) be an index into this
281    /// list rather than a second thing to keep in step with it.
282    fn narrow_weekdays(&self) -> [&str; 7] {
283        ["S", "M", "T", "W", "T", "F", "S"]
284    }
285
286    /// **Which day a week starts on**, as an index into
287    /// [`narrow_weekdays`](Self::narrow_weekdays) — so `0` is Sunday and `1` is Monday
288    /// (`material_localizations.dart`).
289    ///
290    /// Sunday in the United States, Monday across most of Europe, Saturday in much of the
291    /// Middle East. A calendar that always started on Sunday was not merely untranslated:
292    /// it put the days in the wrong columns.
293    fn first_day_of_week_index(&self) -> usize {
294        0
295    }
296
297    /// The months, January first.
298    fn months(&self) -> [&str; 12] {
299        [
300            "January",
301            "February",
302            "March",
303            "April",
304            "May",
305            "June",
306            "July",
307            "August",
308            "September",
309            "October",
310            "November",
311            "December",
312        ]
313    }
314}
315
316/// **The English table**: every method left as the trait wrote it.
317///
318/// It is what [`of`] answers when nothing has been installed, and the sensible thing for
319/// another table to be written against — implement [`Localizations`] and override the
320/// entries that differ.
321#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
322pub struct English;
323
324impl Localizations for English {}
325
326/// **The French table.**
327///
328/// Written rather than generated, and by someone who speaks it: a machine-translated
329/// table is worse than none at all, because it silences the question for that language
330/// and leaves a native reader with something subtly wrong and nobody looking at it.
331///
332/// Three things in here are not translation:
333///
334/// - **The week starts on Monday.** A calendar that always began on Sunday was not
335///   untranslated; it put the days in the wrong columns.
336/// - **The months are lower case**, which is the rule in French and looks like a mistake
337///   to an English eye. "janvier 2026" is correct and "Janvier 2026" is not.
338/// - **`AM` and `PM` stay as they are.** French tells the time on a twenty-four hour
339///   clock and has no everyday words for the halves of a twelve-hour one; the reference's
340///   own French table keeps the Latin abbreviations here too.
341#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
342pub struct French;
343
344impl Localizations for French {
345    fn back_button_label(&self) -> &str {
346        "Retour"
347    }
348
349    fn close_button_label(&self) -> &str {
350        "Fermer"
351    }
352
353    fn open_drawer_label(&self) -> &str {
354        "Ouvrir le menu de navigation"
355    }
356
357    fn tab_label(&self, index: usize, count: usize) -> String {
358        format!("Onglet {index} sur {count}")
359    }
360
361    fn signed_in_label(&self) -> &str {
362        "Connecté"
363    }
364
365    fn show_accounts_label(&self) -> &str {
366        "Afficher les comptes"
367    }
368
369    fn hide_accounts_label(&self) -> &str {
370        "Masquer les comptes"
371    }
372
373    fn clear_button_label(&self) -> &str {
374        "Effacer"
375    }
376
377    /// **"OK" is "OK" in French**, and a table that translated it to look busy would be
378    /// wrong about the language.
379    fn ok_button_label(&self) -> &str {
380        "OK"
381    }
382
383    fn cancel_button_label(&self) -> &str {
384        "Annuler"
385    }
386
387    fn search_field_label(&self) -> &str {
388        "Rechercher"
389    }
390
391    fn no_results_label(&self) -> &str {
392        "Aucun résultat"
393    }
394
395    fn decrease_label(&self) -> &str {
396        "Moins"
397    }
398
399    fn increase_label(&self) -> &str {
400        "Plus"
401    }
402
403    /// **Agreed with the thing selected**, which is why these are four sentences and not
404    /// one formula: *ligne* is feminine, so the participle takes an `e`, and it takes an
405    /// `s` as well once there are several of them.
406    fn row_selected_label(&self) -> &str {
407        "Ligne sélectionnée"
408    }
409
410    fn row_deselected_label(&self) -> &str {
411        "Ligne désélectionnée"
412    }
413
414    fn all_rows_selected_label(&self) -> &str {
415        "Toutes les lignes sélectionnées"
416    }
417
418    fn all_rows_deselected_label(&self) -> &str {
419        "Toutes les lignes désélectionnées"
420    }
421
422    fn hour_label(&self) -> &str {
423        "Heure"
424    }
425
426    /// The same word as the English one, which is the right answer and not a gap.
427    fn minute_label(&self) -> &str {
428        "Minute"
429    }
430
431    /// **Kept as they are.** French tells the time on a twenty-four hour clock; these are
432    /// written out rather than left to the trait so that nothing in this table is silence.
433    fn ante_meridiem_label(&self) -> &str {
434        "AM"
435    }
436
437    /// See [`French::ante_meridiem_label`].
438    fn post_meridiem_label(&self) -> &str {
439        "PM"
440    }
441
442    fn start_label(&self) -> &str {
443        "Début"
444    }
445
446    fn end_label(&self) -> &str {
447        "Fin"
448    }
449
450    /// « 11–20 sur 4 000 ». The word in the middle is *sur* and not *de*, and the numbers
451    /// are grouped with a space.
452    fn page_range_label(&self, start: usize, end: usize, total: usize) -> String {
453        if total == 0 {
454            return "0 sur 0".to_string();
455        }
456        format!(
457            "{}–{} sur {}",
458            self.group_digits(start),
459            self.group_digits(end),
460            self.group_digits(total)
461        )
462    }
463
464    fn rows_per_page_label(&self) -> &str {
465        "Lignes par page"
466    }
467
468    /// **Agreed with the count**, which is why this entry takes one: *ligne* is feminine,
469    /// so the participle is *sélectionnée*, and it takes an `s` from two upwards.
470    fn selected_row_count_label(&self, count: usize) -> String {
471        let plural = if count > 1 { "s" } else { "" };
472        format!("{} sélectionnée{plural}", self.group_digits(count))
473    }
474
475    /// **A no-break space**, which is how French groups a number — and no-break because a
476    /// line that broke between the 4 and the 000 would be two numbers.
477    ///
478    /// Typography asks for a *narrow* one (`U+202F`); this is the ordinary one
479    /// (`U+00A0`), which every font has and which no shaper draws as a missing glyph.
480    fn group_digits(&self, number: usize) -> String {
481        group_with(number, " ")
482    }
483
484    fn previous_month_label(&self) -> &str {
485        "Mois précédent"
486    }
487
488    fn next_month_label(&self) -> &str {
489        "Mois suivant"
490    }
491
492    /// Sunday first, as the trait requires, whatever day the week starts on here:
493    /// dimanche, lundi, mardi, mercredi, jeudi, vendredi, samedi.
494    fn narrow_weekdays(&self) -> [&str; 7] {
495        ["D", "L", "M", "M", "J", "V", "S"]
496    }
497
498    /// **Monday.** Not a translation, and the thing most often left behind by one.
499    fn first_day_of_week_index(&self) -> usize {
500        1
501    }
502
503    fn months(&self) -> [&str; 12] {
504        [
505            "janvier",
506            "février",
507            "mars",
508            "avril",
509            "mai",
510            "juin",
511            "juillet",
512            "août",
513            "septembre",
514            "octobre",
515            "novembre",
516            "décembre",
517        ]
518    }
519}
520
521thread_local! {
522    /// The table in force on this thread. `RefCell` rather than `Cell`, the value being
523    /// a trait object behind an `Rc` and not `Copy`.
524    static AMBIENT: RefCell<Option<Rc<dyn Localizations>>> = const { RefCell::new(None) };
525}
526
527/// **The table in force**, or [`English`] when nothing has been installed.
528///
529/// Cheap enough to call from a paint: it is a reference count, not a table.
530#[inline(never)]
531pub fn of() -> Rc<dyn Localizations> {
532    AMBIENT.with(|ambient| {
533        ambient
534            .borrow_mut()
535            .get_or_insert_with(|| Rc::new(English) as Rc<dyn Localizations>)
536            .clone()
537    })
538}
539
540/// Installs `table` for this thread, from now on.
541///
542/// The shell does this every frame from [`Application::localizations`], so an application
543/// that changes language while it is running is obeyed on the next frame.
544///
545/// [`Application::localizations`]: https://docs.rs/frus-shell
546#[inline(never)]
547pub fn install(table: Rc<dyn Localizations>) {
548    AMBIENT.with(|ambient| *ambient.borrow_mut() = Some(table));
549}
550
551/// Runs `f` with `table` in force, and puts back whatever was there before — including
552/// when `f` panics, so one bad frame cannot leave a stale language installed for every
553/// frame after it.
554pub fn scope<R>(table: Rc<dyn Localizations>, f: impl FnOnce() -> R) -> R {
555    let _restore = Restore(swap(Some(table)));
556    f()
557}
558
559/// Puts `next` in place, and gives back what was there. Not generic, and not inlined: see
560/// `component.rs` on thread-locals and hot patches.
561#[inline(never)]
562fn swap(next: Option<Rc<dyn Localizations>>) -> Option<Rc<dyn Localizations>> {
563    AMBIENT.with(|ambient| std::mem::replace(&mut *ambient.borrow_mut(), next))
564}
565
566/// Puts back the previous table when dropped, panic or not.
567struct Restore(Option<Rc<dyn Localizations>>);
568
569impl Drop for Restore {
570    fn drop(&mut self) {
571        swap(self.0.take());
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    /// A table that starts its weeks on Monday and says so in French.
580    struct Fr;
581
582    impl Localizations for Fr {
583        fn back_button_label(&self) -> &str {
584            "Retour"
585        }
586
587        fn first_day_of_week_index(&self) -> usize {
588            1
589        }
590
591        fn narrow_weekdays(&self) -> [&str; 7] {
592            ["D", "L", "M", "M", "J", "V", "S"]
593        }
594    }
595
596    /// Reads this file back, with its line endings normalised — a checkout on Windows
597    /// hands it over with CRLF while the patterns below are written with `\n`.
598    fn source() -> String {
599        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/localizations.rs");
600        std::fs::read_to_string(path)
601            .unwrap_or_else(|e| panic!("reading {path}: {e}"))
602            .replace("\r\n", "\n")
603    }
604
605    /// The names of the methods declared between `start` and the end of its block.
606    fn methods_in(source: &str, start: &str) -> Vec<String> {
607        let body = &source[source.find(start).expect("the block") + start.len()..];
608        let end = body.find("\n}\n").expect("the end of the block");
609        body[..end]
610            .lines()
611            .filter_map(|line| line.trim().strip_prefix("fn "))
612            .map(|rest| rest[..rest.find(['(', '<']).unwrap_or(rest.len())].to_string())
613            .collect()
614    }
615
616    /// **A French table answers every entry the trait declares**, checked against the
617    /// trait itself rather than against a list kept up to date by hand.
618    ///
619    /// This is the bug the whole module is about, one level down: an entry left at its
620    /// default is an **English string in a French interface**, and it compiles, and no
621    /// test that only checks the entries someone remembered would ever say so. An entry
622    /// whose right answer in French *is* the English one — "OK", "Minute", "AM" — is
623    /// written out with a reason beside it, so silence never means agreement.
624    #[test]
625    fn the_french_table_answers_every_entry_the_trait_declares() {
626        let source = source();
627        let declared = methods_in(&source, "pub trait Localizations {");
628        let answered = methods_in(&source, "impl Localizations for French {");
629        assert!(declared.len() >= 25, "found only {declared:?}");
630        for entry in &declared {
631            assert!(
632                answered.contains(entry),
633                "`{entry}` is not in the French table, so a French reader gets the English words: {answered:?}"
634            );
635        }
636    }
637
638    /// **And every one of them is a French sentence**, save the few whose right answer in
639    /// French is the English one. A machine-translated table would pass the test above and
640    /// fail this one on nothing at all; this is what catches an entry answered by copying.
641    #[test]
642    fn the_french_words_are_not_the_english_ones() {
643        // "OK" is "OK" in a good many languages, "Minute" is spelt the same in both, and
644        // French has no everyday words for the halves of a clock it does not use.
645        let same_on_purpose = ["OK", "Minute", "AM", "PM"];
646        let (en, fr) = (English, French);
647        let entries: Vec<(&str, &str, &str)> = vec![
648            ("back", en.back_button_label(), fr.back_button_label()),
649            ("close", en.close_button_label(), fr.close_button_label()),
650            ("drawer", en.open_drawer_label(), fr.open_drawer_label()),
651            ("signed in", en.signed_in_label(), fr.signed_in_label()),
652            (
653                "show accounts",
654                en.show_accounts_label(),
655                fr.show_accounts_label(),
656            ),
657            (
658                "hide accounts",
659                en.hide_accounts_label(),
660                fr.hide_accounts_label(),
661            ),
662            ("clear", en.clear_button_label(), fr.clear_button_label()),
663            ("ok", en.ok_button_label(), fr.ok_button_label()),
664            ("cancel", en.cancel_button_label(), fr.cancel_button_label()),
665            ("search", en.search_field_label(), fr.search_field_label()),
666            ("no results", en.no_results_label(), fr.no_results_label()),
667            ("decrease", en.decrease_label(), fr.decrease_label()),
668            ("increase", en.increase_label(), fr.increase_label()),
669            (
670                "row selected",
671                en.row_selected_label(),
672                fr.row_selected_label(),
673            ),
674            (
675                "row deselected",
676                en.row_deselected_label(),
677                fr.row_deselected_label(),
678            ),
679            (
680                "all selected",
681                en.all_rows_selected_label(),
682                fr.all_rows_selected_label(),
683            ),
684            (
685                "all deselected",
686                en.all_rows_deselected_label(),
687                fr.all_rows_deselected_label(),
688            ),
689            ("hour", en.hour_label(), fr.hour_label()),
690            ("minute", en.minute_label(), fr.minute_label()),
691            ("am", en.ante_meridiem_label(), fr.ante_meridiem_label()),
692            ("pm", en.post_meridiem_label(), fr.post_meridiem_label()),
693            ("start", en.start_label(), fr.start_label()),
694            ("end", en.end_label(), fr.end_label()),
695            (
696                "rows per page",
697                en.rows_per_page_label(),
698                fr.rows_per_page_label(),
699            ),
700            (
701                "previous month",
702                en.previous_month_label(),
703                fr.previous_month_label(),
704            ),
705            ("next month", en.next_month_label(), fr.next_month_label()),
706        ];
707        for (what, english, french) in entries {
708            if same_on_purpose.contains(&french) {
709                continue;
710            }
711            assert_ne!(english, french, "{what} was not translated");
712        }
713        assert_ne!(en.tab_label(1, 3), fr.tab_label(1, 3));
714        assert_ne!(
715            en.page_range_label(11, 20, 4000),
716            fr.page_range_label(11, 20, 4000)
717        );
718        assert_ne!(
719            en.selected_row_count_label(3),
720            fr.selected_row_count_label(3)
721        );
722        assert_ne!(en.group_digits(4000), fr.group_digits(4000));
723        assert_ne!(en.months(), fr.months());
724        assert_ne!(en.narrow_weekdays(), fr.narrow_weekdays());
725    }
726
727    /// **The parts that are not words.** A week that starts on Monday and a month written
728    /// in lower case are the two things a translation of the strings alone would leave
729    /// behind, and the first of them puts the days in the wrong columns.
730    #[test]
731    fn the_french_table_gets_the_parts_that_are_not_words_right() {
732        assert_eq!(French.first_day_of_week_index(), 1, "lundi");
733        assert_eq!(
734            French.narrow_weekdays()[French.first_day_of_week_index()],
735            "L",
736            "the index is into a list that always starts on Sunday"
737        );
738        for month in French.months() {
739            assert_eq!(
740                month.chars().next().unwrap().to_lowercase().to_string(),
741                month.chars().next().unwrap().to_string(),
742                "{month} is capitalised, which is an English habit"
743            );
744        }
745    }
746
747    /// **The words reach the widgets that say them.**
748    ///
749    /// A table nobody reads is a table that translates nothing, and until this milestone
750    /// five widgets said their own English out loud where no table could reach it. This
751    /// walks the ones that were rewired and looks for French on the far side — through the
752    /// scene and the accessibility tree, not through the table it already tested.
753    ///
754    /// Each is built **inside** the scope, which is the contract: a widget composes its
755    /// children when it is constructed, so it speaks the language in force at that moment.
756    /// In an application that is every frame, the shell installing the table before the
757    /// view is built.
758    #[test]
759    fn the_words_reach_the_widgets_that_say_them() {
760        use crate::theme::Theme;
761        use crate::ui::build_ui;
762        use frus_core::Size;
763
764        scope(Rc::new(French), || {
765            // A calendar: the month over it, and the arrows either side of it.
766            let picker = crate::DatePicker::new(2026, 1, Some(15), |_: u32| (), |_: i32| ());
767            let ui = build_ui(
768                &picker,
769                Size::new(320.0, 360.0),
770                &crate::Runtime::default(),
771                &Theme::default(),
772            );
773            let words: Vec<String> = ui
774                .scene()
775                .primitives()
776                .iter()
777                .filter_map(|p| match p {
778                    frus_core::Primitive::Text { text, .. } => Some(text.clone()),
779                    _ => None,
780                })
781                .collect();
782            assert!(
783                words.iter().any(|w| w == "janvier 2026"),
784                "the month, in lower case: {words:?}"
785            );
786            let labels: Vec<String> = ui
787                .semantics()
788                .iter()
789                .filter_map(|(_, _, s)| s.label.clone())
790                .collect();
791            assert!(
792                labels.iter().any(|l| l == "Mois précédent"),
793                "the arrows: {labels:?}"
794            );
795
796            // A stepper: two buttons whose glyphs are a minus and a plus, and which say
797            // nothing at all out loud without these.
798            let stepper = crate::Stepper::new(3, |_: i32| ());
799            let buttons: Vec<String> = crate::widget::Widget::<()>::children(&stepper)
800                .iter()
801                .filter_map(|child| child.semantics().and_then(|s| s.label))
802                .collect();
803            assert_eq!(buttons, vec!["Moins".to_string(), "Plus".to_string()]);
804
805            // A table announcing what a tick will do.
806            let table = crate::Table::<()>::new(2)
807                .header(&["Nom", "Note"])
808                .checkboxes(|_| (), ())
809                .row(&["Ada", "5"]);
810            let row = crate::widget::Widget::<()>::children(&table)[1].children()[0].announce();
811            assert_eq!(row.as_deref(), Some("Ligne sélectionnée"));
812        });
813    }
814
815    /// **Nothing installed still answers**, in English — which is what every string
816    /// constant this replaces already said.
817    #[test]
818    fn the_words_are_english_until_someone_says_otherwise() {
819        assert_eq!(of().back_button_label(), "Back");
820        assert_eq!(of().close_button_label(), "Close");
821        assert_eq!(of().first_day_of_week_index(), 0);
822        assert_eq!(of().months()[0], "January");
823    }
824
825    /// A table overrides only what differs, and the rest stays as the trait wrote it.
826    #[test]
827    fn a_table_says_only_what_differs() {
828        scope(Rc::new(Fr), || {
829            assert_eq!(of().back_button_label(), "Retour");
830            assert_eq!(of().first_day_of_week_index(), 1);
831            assert_eq!(
832                of().close_button_label(),
833                "Close",
834                "not said, so the trait's own answer stands"
835            );
836        });
837    }
838
839    /// And a scope puts back what it found, **including when what it runs panics** — one
840    /// bad frame must not leave a language installed for every frame after it.
841    #[test]
842    fn a_scope_puts_back_what_it_found() {
843        assert_eq!(of().back_button_label(), "Back");
844        scope(Rc::new(Fr), || {
845            assert_eq!(of().back_button_label(), "Retour");
846        });
847        assert_eq!(of().back_button_label(), "Back", "restored");
848
849        let fell_over = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
850            scope(Rc::new(Fr), || panic!("one bad frame"));
851        }));
852        assert!(fell_over.is_err());
853        assert_eq!(
854            of().back_button_label(),
855            "Back",
856            "restored through the unwind as well"
857        );
858    }
859}