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.
530pub fn of() -> Rc<dyn Localizations> {
531    AMBIENT.with(|ambient| {
532        ambient
533            .borrow_mut()
534            .get_or_insert_with(|| Rc::new(English) as Rc<dyn Localizations>)
535            .clone()
536    })
537}
538
539/// Installs `table` for this thread, from now on.
540///
541/// The shell does this every frame from [`Application::localizations`], so an application
542/// that changes language while it is running is obeyed on the next frame.
543///
544/// [`Application::localizations`]: https://docs.rs/frus-shell
545pub fn install(table: Rc<dyn Localizations>) {
546    AMBIENT.with(|ambient| *ambient.borrow_mut() = Some(table));
547}
548
549/// Runs `f` with `table` in force, and puts back whatever was there before — including
550/// when `f` panics, so one bad frame cannot leave a stale language installed for every
551/// frame after it.
552pub fn scope<R>(table: Rc<dyn Localizations>, f: impl FnOnce() -> R) -> R {
553    let previous = AMBIENT.with(|ambient| ambient.borrow_mut().replace(table));
554    let _restore = Restore(previous);
555    f()
556}
557
558/// Puts back the previous table when dropped, panic or not.
559struct Restore(Option<Rc<dyn Localizations>>);
560
561impl Drop for Restore {
562    fn drop(&mut self) {
563        let previous = self.0.take();
564        AMBIENT.with(|ambient| *ambient.borrow_mut() = previous);
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    /// A table that starts its weeks on Monday and says so in French.
573    struct Fr;
574
575    impl Localizations for Fr {
576        fn back_button_label(&self) -> &str {
577            "Retour"
578        }
579
580        fn first_day_of_week_index(&self) -> usize {
581            1
582        }
583
584        fn narrow_weekdays(&self) -> [&str; 7] {
585            ["D", "L", "M", "M", "J", "V", "S"]
586        }
587    }
588
589    /// Reads this file back, with its line endings normalised — a checkout on Windows
590    /// hands it over with CRLF while the patterns below are written with `\n`.
591    fn source() -> String {
592        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/localizations.rs");
593        std::fs::read_to_string(path)
594            .unwrap_or_else(|e| panic!("reading {path}: {e}"))
595            .replace("\r\n", "\n")
596    }
597
598    /// The names of the methods declared between `start` and the end of its block.
599    fn methods_in(source: &str, start: &str) -> Vec<String> {
600        let body = &source[source.find(start).expect("the block") + start.len()..];
601        let end = body.find("\n}\n").expect("the end of the block");
602        body[..end]
603            .lines()
604            .filter_map(|line| line.trim().strip_prefix("fn "))
605            .map(|rest| rest[..rest.find(['(', '<']).unwrap_or(rest.len())].to_string())
606            .collect()
607    }
608
609    /// **A French table answers every entry the trait declares**, checked against the
610    /// trait itself rather than against a list kept up to date by hand.
611    ///
612    /// This is the bug the whole module is about, one level down: an entry left at its
613    /// default is an **English string in a French interface**, and it compiles, and no
614    /// test that only checks the entries someone remembered would ever say so. An entry
615    /// whose right answer in French *is* the English one — "OK", "Minute", "AM" — is
616    /// written out with a reason beside it, so silence never means agreement.
617    #[test]
618    fn the_french_table_answers_every_entry_the_trait_declares() {
619        let source = source();
620        let declared = methods_in(&source, "pub trait Localizations {");
621        let answered = methods_in(&source, "impl Localizations for French {");
622        assert!(declared.len() >= 25, "found only {declared:?}");
623        for entry in &declared {
624            assert!(
625                answered.contains(entry),
626                "`{entry}` is not in the French table, so a French reader gets the English words: {answered:?}"
627            );
628        }
629    }
630
631    /// **And every one of them is a French sentence**, save the few whose right answer in
632    /// French is the English one. A machine-translated table would pass the test above and
633    /// fail this one on nothing at all; this is what catches an entry answered by copying.
634    #[test]
635    fn the_french_words_are_not_the_english_ones() {
636        // "OK" is "OK" in a good many languages, "Minute" is spelt the same in both, and
637        // French has no everyday words for the halves of a clock it does not use.
638        let same_on_purpose = ["OK", "Minute", "AM", "PM"];
639        let (en, fr) = (English, French);
640        let entries: Vec<(&str, &str, &str)> = vec![
641            ("back", en.back_button_label(), fr.back_button_label()),
642            ("close", en.close_button_label(), fr.close_button_label()),
643            ("drawer", en.open_drawer_label(), fr.open_drawer_label()),
644            ("signed in", en.signed_in_label(), fr.signed_in_label()),
645            (
646                "show accounts",
647                en.show_accounts_label(),
648                fr.show_accounts_label(),
649            ),
650            (
651                "hide accounts",
652                en.hide_accounts_label(),
653                fr.hide_accounts_label(),
654            ),
655            ("clear", en.clear_button_label(), fr.clear_button_label()),
656            ("ok", en.ok_button_label(), fr.ok_button_label()),
657            ("cancel", en.cancel_button_label(), fr.cancel_button_label()),
658            ("search", en.search_field_label(), fr.search_field_label()),
659            ("no results", en.no_results_label(), fr.no_results_label()),
660            ("decrease", en.decrease_label(), fr.decrease_label()),
661            ("increase", en.increase_label(), fr.increase_label()),
662            (
663                "row selected",
664                en.row_selected_label(),
665                fr.row_selected_label(),
666            ),
667            (
668                "row deselected",
669                en.row_deselected_label(),
670                fr.row_deselected_label(),
671            ),
672            (
673                "all selected",
674                en.all_rows_selected_label(),
675                fr.all_rows_selected_label(),
676            ),
677            (
678                "all deselected",
679                en.all_rows_deselected_label(),
680                fr.all_rows_deselected_label(),
681            ),
682            ("hour", en.hour_label(), fr.hour_label()),
683            ("minute", en.minute_label(), fr.minute_label()),
684            ("am", en.ante_meridiem_label(), fr.ante_meridiem_label()),
685            ("pm", en.post_meridiem_label(), fr.post_meridiem_label()),
686            ("start", en.start_label(), fr.start_label()),
687            ("end", en.end_label(), fr.end_label()),
688            (
689                "rows per page",
690                en.rows_per_page_label(),
691                fr.rows_per_page_label(),
692            ),
693            (
694                "previous month",
695                en.previous_month_label(),
696                fr.previous_month_label(),
697            ),
698            ("next month", en.next_month_label(), fr.next_month_label()),
699        ];
700        for (what, english, french) in entries {
701            if same_on_purpose.contains(&french) {
702                continue;
703            }
704            assert_ne!(english, french, "{what} was not translated");
705        }
706        assert_ne!(en.tab_label(1, 3), fr.tab_label(1, 3));
707        assert_ne!(
708            en.page_range_label(11, 20, 4000),
709            fr.page_range_label(11, 20, 4000)
710        );
711        assert_ne!(
712            en.selected_row_count_label(3),
713            fr.selected_row_count_label(3)
714        );
715        assert_ne!(en.group_digits(4000), fr.group_digits(4000));
716        assert_ne!(en.months(), fr.months());
717        assert_ne!(en.narrow_weekdays(), fr.narrow_weekdays());
718    }
719
720    /// **The parts that are not words.** A week that starts on Monday and a month written
721    /// in lower case are the two things a translation of the strings alone would leave
722    /// behind, and the first of them puts the days in the wrong columns.
723    #[test]
724    fn the_french_table_gets_the_parts_that_are_not_words_right() {
725        assert_eq!(French.first_day_of_week_index(), 1, "lundi");
726        assert_eq!(
727            French.narrow_weekdays()[French.first_day_of_week_index()],
728            "L",
729            "the index is into a list that always starts on Sunday"
730        );
731        for month in French.months() {
732            assert_eq!(
733                month.chars().next().unwrap().to_lowercase().to_string(),
734                month.chars().next().unwrap().to_string(),
735                "{month} is capitalised, which is an English habit"
736            );
737        }
738    }
739
740    /// **The words reach the widgets that say them.**
741    ///
742    /// A table nobody reads is a table that translates nothing, and until this milestone
743    /// five widgets said their own English out loud where no table could reach it. This
744    /// walks the ones that were rewired and looks for French on the far side — through the
745    /// scene and the accessibility tree, not through the table it already tested.
746    ///
747    /// Each is built **inside** the scope, which is the contract: a widget composes its
748    /// children when it is constructed, so it speaks the language in force at that moment.
749    /// In an application that is every frame, the shell installing the table before the
750    /// view is built.
751    #[test]
752    fn the_words_reach_the_widgets_that_say_them() {
753        use crate::theme::Theme;
754        use crate::ui::build_ui;
755        use frus_core::Size;
756
757        scope(Rc::new(French), || {
758            // A calendar: the month over it, and the arrows either side of it.
759            let picker = crate::DatePicker::new(2026, 1, Some(15), |_: u32| (), |_: i32| ());
760            let ui = build_ui(
761                &picker,
762                Size::new(320.0, 360.0),
763                &crate::Runtime::default(),
764                &Theme::default(),
765            );
766            let words: Vec<String> = ui
767                .scene()
768                .primitives()
769                .iter()
770                .filter_map(|p| match p {
771                    frus_core::Primitive::Text { text, .. } => Some(text.clone()),
772                    _ => None,
773                })
774                .collect();
775            assert!(
776                words.iter().any(|w| w == "janvier 2026"),
777                "the month, in lower case: {words:?}"
778            );
779            let labels: Vec<String> = ui
780                .semantics()
781                .iter()
782                .filter_map(|(_, _, s)| s.label.clone())
783                .collect();
784            assert!(
785                labels.iter().any(|l| l == "Mois précédent"),
786                "the arrows: {labels:?}"
787            );
788
789            // A stepper: two buttons whose glyphs are a minus and a plus, and which say
790            // nothing at all out loud without these.
791            let stepper = crate::Stepper::new(3, |_: i32| ());
792            let buttons: Vec<String> = crate::widget::Widget::<()>::children(&stepper)
793                .iter()
794                .filter_map(|child| child.semantics().and_then(|s| s.label))
795                .collect();
796            assert_eq!(buttons, vec!["Moins".to_string(), "Plus".to_string()]);
797
798            // A table announcing what a tick will do.
799            let table = crate::Table::<()>::new(2)
800                .header(&["Nom", "Note"])
801                .checkboxes(|_| (), ())
802                .row(&["Ada", "5"]);
803            let row = crate::widget::Widget::<()>::children(&table)[1].children()[0].announce();
804            assert_eq!(row.as_deref(), Some("Ligne sélectionnée"));
805        });
806    }
807
808    /// **Nothing installed still answers**, in English — which is what every string
809    /// constant this replaces already said.
810    #[test]
811    fn the_words_are_english_until_someone_says_otherwise() {
812        assert_eq!(of().back_button_label(), "Back");
813        assert_eq!(of().close_button_label(), "Close");
814        assert_eq!(of().first_day_of_week_index(), 0);
815        assert_eq!(of().months()[0], "January");
816    }
817
818    /// A table overrides only what differs, and the rest stays as the trait wrote it.
819    #[test]
820    fn a_table_says_only_what_differs() {
821        scope(Rc::new(Fr), || {
822            assert_eq!(of().back_button_label(), "Retour");
823            assert_eq!(of().first_day_of_week_index(), 1);
824            assert_eq!(
825                of().close_button_label(),
826                "Close",
827                "not said, so the trait's own answer stands"
828            );
829        });
830    }
831
832    /// And a scope puts back what it found, **including when what it runs panics** — one
833    /// bad frame must not leave a language installed for every frame after it.
834    #[test]
835    fn a_scope_puts_back_what_it_found() {
836        assert_eq!(of().back_button_label(), "Back");
837        scope(Rc::new(Fr), || {
838            assert_eq!(of().back_button_label(), "Retour");
839        });
840        assert_eq!(of().back_button_label(), "Back", "restored");
841
842        let fell_over = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
843            scope(Rc::new(Fr), || panic!("one bad frame"));
844        }));
845        assert!(fell_over.is_err());
846        assert_eq!(
847            of().back_button_label(),
848            "Back",
849            "restored through the unwind as well"
850        );
851    }
852}