Skip to main content

qcode/ui/setup/
languages.rs

1//! Which language stands where on the wizard's first page, and which one is already chosen.
2//!
3//! Two rules, both the person's: the machine's own language is the first row and the one that is
4//! chosen when nothing has been chosen before, and the rest of the list reads alphabetically.
5//! A machine that names no language QCode speaks gets English in that place, which is also the
6//! language the framework falls back to, so the row that looks chosen is the language the screen
7//! is really being drawn in.
8//!
9//! The order is worked out once, when the wizard is built, and then held. Sorting the rows again
10//! at every draw would shuffle the list under the hand of someone who has just picked a language,
11//! which is the one moment they are looking straight at it.
12
13use qframe::i18n::I18n;
14
15use crate::store::Config;
16
17/// The language QCode speaks when the machine names none, which is also the framework's own
18/// fallback.
19const FALLBACK: &str = "en";
20
21/// The order the languages are offered in.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Languages {
24    order: Vec<&'static str>,
25}
26
27impl Languages {
28    /// The order for a machine whose own language is `system`, with `name` giving a language the
29    /// name it is shown by.
30    ///
31    /// `system` may be anything; only a language QCode speaks takes the first row, and anything
32    /// else leaves it to English.
33    #[must_use]
34    pub fn new(system: Option<&str>, name: impl Fn(&str) -> String) -> Self {
35        let first =
36            system.and_then(|wanted| Config::LANGUAGES.into_iter().find(|known| *known == wanted)).unwrap_or(FALLBACK);
37        let mut rest: Vec<&'static str> = Config::LANGUAGES.into_iter().filter(|code| *code != first).collect();
38        rest.sort_by_cached_key(|code| sort_key(&name(code)));
39        let mut order = Vec::with_capacity(Config::LANGUAGES.len());
40        order.push(first);
41        order.extend(rest);
42        Self { order }
43    }
44
45    /// The order for a machine whose own language is `system`, with the names as they read in
46    /// `speaking`, the language the wizard is about to be drawn in.
47    #[must_use]
48    pub fn shown_in(system: Option<&str>, speaking: &str) -> Self {
49        let mut catalog = I18n::builtin();
50        for (file, text) in crate::locales() {
51            catalog.add_source(&file, &text);
52        }
53        catalog.set_active(speaking);
54        Self::new(system, |code| catalog.translate(&format!("setup.language-{code}"), &[]))
55    }
56
57    /// The languages, first row first.
58    #[must_use]
59    pub fn codes(&self) -> &[&'static str] {
60        &self.order
61    }
62
63    /// The row `code` stands on, or the first row for a language that is not offered.
64    #[must_use]
65    pub fn row_of(&self, code: &str) -> usize {
66        self.order.iter().position(|known| *known == code).unwrap_or(0)
67    }
68
69    /// The language on row `row`, or the first one when there is no such row.
70    #[must_use]
71    pub fn on_row(&self, row: usize) -> &'static str {
72        self.order.get(row).copied().unwrap_or(self.order[0])
73    }
74}
75
76/// The language this machine is set to, among the ones QCode speaks, read through `lookup`.
77///
78/// The reading itself is the framework's: the same locale names, the same order of environment
79/// variables and the same matching that every Quvyta application follows.
80#[must_use]
81pub fn system(lookup: impl Fn(&str) -> Option<String>) -> Option<String> {
82    let found = I18n::builtin().detect(lookup)?;
83    Config::LANGUAGES.into_iter().find(|known| *known == found).map(str::to_owned)
84}
85
86/// The language this machine is set to, read from the environment it was started in.
87#[must_use]
88pub fn system_here() -> Option<String> {
89    system(|name| std::env::var(name).ok())
90}
91
92/// What a name is sorted by: its letters without case and without the marks above and below
93/// them, so a Turkish list does not send `Çince` past `Rusça` for the sake of a cedilla.
94///
95/// Writing systems of their own sort by their own code points, which keeps them together and in
96/// the same order every time.
97fn sort_key(name: &str) -> String {
98    name.to_lowercase().chars().map(fold).collect()
99}
100
101/// The plain letter a marked one is sorted as.
102fn fold(letter: char) -> char {
103    match letter {
104        'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'ā' => 'a',
105        'ç' | 'ć' | 'č' => 'c',
106        'é' | 'è' | 'ê' | 'ë' | 'ē' => 'e',
107        'ğ' | 'ģ' => 'g',
108        'í' | 'ì' | 'î' | 'ï' | 'ī' | 'ı' => 'i',
109        'ñ' | 'ń' => 'n',
110        'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'ø' | 'ō' => 'o',
111        'ş' | 'ś' | 'š' => 's',
112        'ú' | 'ù' | 'û' | 'ü' | 'ū' => 'u',
113        'ý' | 'ÿ' => 'y',
114        'ž' | 'ź' | 'ż' => 'z',
115        // The combining dot Turkish `İ` leaves behind once the case is gone.
116        '\u{0307}' => '\0',
117        other => other,
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::{Languages, system};
124
125    fn english(code: &str) -> String {
126        match code {
127            "en" => "English",
128            "tr" => "Turkish",
129            "de" => "German",
130            "es" => "Spanish",
131            "fr" => "French",
132            "pt-BR" => "Portuguese (Brazil)",
133            "ru" => "Russian",
134            "zh-Hans" => "Chinese (Simplified)",
135            _ => "Japanese",
136        }
137        .to_owned()
138    }
139
140    fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
141        move |name: &str| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned())
142    }
143
144    #[test]
145    fn the_machines_own_language_takes_the_first_row() {
146        let order = Languages::new(Some("tr"), english);
147        assert_eq!(order.codes()[0], "tr");
148        assert_eq!(order.row_of("tr"), 0);
149    }
150
151    #[test]
152    fn a_machine_that_names_no_language_qcode_speaks_gets_english_first() {
153        // Finnish is a language QCode has no words for, and a machine set to `C` names none at
154        // all: both leave the first row to English rather than to whatever happens to be first.
155        for system in [None, Some("fi"), Some("")] {
156            let order = Languages::new(system, english);
157            assert_eq!(order.codes()[0], "en", "{system:?}");
158        }
159    }
160
161    #[test]
162    fn the_rest_of_the_list_reads_alphabetically() {
163        let order = Languages::new(Some("tr"), english);
164        assert_eq!(
165            order.codes(),
166            ["tr", "zh-Hans", "en", "fr", "de", "ja", "pt-BR", "ru", "es"],
167            "Chinese, English, French, German, Japanese, Portuguese, Russian, Spanish"
168        );
169    }
170
171    #[test]
172    fn the_first_row_is_not_repeated_further_down() {
173        let order = Languages::new(Some("de"), english);
174        assert_eq!(order.codes().len(), 9);
175        assert_eq!(order.codes().iter().filter(|code| **code == "de").count(), 1);
176    }
177
178    #[test]
179    fn a_marked_letter_sorts_as_the_plain_one() {
180        // The Turkish names of the nine, where a cedilla on `Çince` would otherwise throw it
181        // past every unmarked name to the very end of the list.
182        let turkish = |code: &str| {
183            match code {
184                "en" => "İngilizce",
185                "tr" => "Türkçe",
186                "de" => "Almanca",
187                "es" => "İspanyolca",
188                "fr" => "Fransızca",
189                "pt-BR" => "Portekizce (Brezilya)",
190                "ru" => "Rusça",
191                "zh-Hans" => "Çince (Basitleştirilmiş)",
192                _ => "Japonca",
193            }
194            .to_owned()
195        };
196        let order = Languages::new(Some("tr"), turkish);
197        assert_eq!(order.codes(), ["tr", "de", "zh-Hans", "fr", "en", "es", "ja", "pt-BR", "ru"]);
198    }
199
200    #[test]
201    fn the_language_of_the_machine_is_read_the_way_every_quvyta_application_reads_it() {
202        assert_eq!(system(env(&[("LANG", "tr_TR.UTF-8")])), Some("tr".to_owned()));
203        assert_eq!(system(env(&[("LC_ALL", "pt_BR.UTF-8"), ("LANG", "tr_TR.UTF-8")])), Some("pt-BR".to_owned()));
204        assert_eq!(system(env(&[("LANG", "C")])), None);
205        // An empty lookup is not an empty answer: the framework falls through to the operating
206        // system's own setting, which is the machine the test is running on.
207    }
208
209    #[test]
210    fn the_names_the_order_is_built_from_are_the_ones_the_wizard_shows() {
211        // Built from QCode's own files rather than a test's copy of them: a name that changes in
212        // `tr.toml` moves its row here too.
213        let order = Languages::shown_in(Some("tr"), "tr");
214        assert_eq!(order.codes(), ["tr", "de", "zh-Hans", "fr", "en", "es", "ja", "pt-BR", "ru"]);
215    }
216}