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}