| 1 | //! Language picker for first-run onboarding (#566). |
| 2 | //! |
| 3 | //! Surfaces every locale the TUI ships translations for, plus an `auto` |
| 4 | //! option that defers to `LC_ALL` / `LANG`. Selection persists via |
| 5 | //! `Settings::save` immediately so the rest of onboarding (and every |
| 6 | //! subsequent session) reads the chosen tag. |
| 7 | |
| 8 | use ratatui::style::{Modifier, Style}; |
| 9 | use ratatui::text::{Line, Span}; |
| 10 | |
| 11 | use crate::localization::MessageId; |
| 12 | use crate::palette; |
| 13 | use crate::tui::app::App; |
| 14 | |
| 15 | /// Locale options shown in the picker. Order matches the keyboard hotkeys. |
| 16 | /// Each entry is `(hotkey, settings_tag, native_name, english_label)`. |
| 17 | /// `settings_tag` is what `Settings::set("locale", …)` accepts and what |
| 18 | /// `localization::Locale` resolves on next read. |
| 19 | /// |
| 20 | /// Hotkeys run `1..=9` then `a`, `b`, … so more than nine shipped locales |
| 21 | /// stay single-keystroke selectable. |
| 22 | pub const LANGUAGE_OPTIONS: &[(char, &str, &str, &str)] = &[ |
| 23 | ('1', "auto", "Auto-detect", "(LC_ALL / LANG)"), |
| 24 | ('2', "en", "English", ""), |
| 25 | ('3', "ja", "日本語", "(Japanese)"), |
| 26 | ('4', "zh-Hans", "简体中文", "(Simplified Chinese)"), |
| 27 | ('5', "zh-Hant", "繁體中文", "(Traditional Chinese)"), |
| 28 | ('6', "pt-BR", "Português (Brasil)", "(Brazilian Portuguese)"), |
| 29 | ( |
| 30 | '7', |
| 31 | "es-419", |
| 32 | "Español (Latinoamérica)", |
| 33 | "(Latin American Spanish)", |
| 34 | ), |
| 35 | ('8', "vi", "Tiếng Việt", "(Vietnamese)"), |
| 36 | ('9', "ko", "한국어", "(Korean)"), |
| 37 | ('a', "ca", "Català", "(Catalan)"), |
| 38 | ('b', "de", "Deutsch", "(German)"), |
| 39 | ('c', "fr", "Français", "(French)"), |
| 40 | ('d', "id", "Bahasa Indonesia", "(Indonesian)"), |
| 41 | ('e', "hi", "हिन्दी", "(Hindi)"), |
| 42 | ('f', "ru", "Русский", "(Russian)"), |
| 43 | ('g', "uk", "Українська", "(Ukrainian)"), |
| 44 | ]; |
| 45 | |
| 46 | pub fn lines(app: &App) -> Vec<Line<'static>> { |
| 47 | let current_owned = app.current_locale_tag(); |
| 48 | let current = current_owned.as_str(); |
| 49 | |
| 50 | let mut out: Vec<Line<'static>> = vec![ |
| 51 | Line::from(Span::styled( |
| 52 | app.tr(MessageId::OnboardLanguageTitle).to_string(), |
| 53 | Style::default() |
| 54 | .fg(palette::WHALE_INFO) |
| 55 | .add_modifier(Modifier::BOLD), |
| 56 | )), |
| 57 | Line::from(""), |
| 58 | Line::from(Span::styled( |
| 59 | app.tr(MessageId::OnboardLanguageBlurb).to_string(), |
| 60 | Style::default().fg(palette::TEXT_MUTED), |
| 61 | )), |
| 62 | Line::from(""), |
| 63 | ]; |
| 64 | |
| 65 | for (hotkey, tag, native, english) in LANGUAGE_OPTIONS { |
| 66 | let is_current = current == *tag; |
| 67 | let bullet = if is_current { |
| 68 | crate::tui::glyphs::CURRENT |
| 69 | } else { |
| 70 | crate::tui::glyphs::AVAILABLE |
| 71 | }; |
| 72 | let bullet_color = if is_current { |
| 73 | palette::WHALE_ACTION |
| 74 | } else { |
| 75 | palette::TEXT_MUTED |
| 76 | }; |
| 77 | let mut spans: Vec<Span<'static>> = vec![ |
| 78 | Span::styled(format!(" {bullet} "), Style::default().fg(bullet_color)), |
| 79 | Span::styled( |
| 80 | format!("[{hotkey}] "), |
| 81 | Style::default() |
| 82 | .fg(palette::TEXT_PRIMARY) |
| 83 | .add_modifier(Modifier::BOLD), |
| 84 | ), |
| 85 | Span::styled( |
| 86 | native.to_string(), |
| 87 | Style::default().fg(palette::TEXT_PRIMARY), |
| 88 | ), |
| 89 | ]; |
| 90 | if !english.is_empty() { |
| 91 | spans.push(Span::styled( |
| 92 | format!(" {english}"), |
| 93 | Style::default().fg(palette::TEXT_MUTED), |
| 94 | )); |
| 95 | } |
| 96 | out.push(Line::from(spans)); |
| 97 | } |
| 98 | |
| 99 | out.push(Line::from("")); |
| 100 | out.push(Line::from(Span::styled( |
| 101 | app.tr(MessageId::OnboardLanguageFooter).to_string(), |
| 102 | Style::default().fg(palette::TEXT_MUTED), |
| 103 | ))); |
| 104 | |
| 105 | out |
| 106 | } |
| 107 | |
| 108 | #[cfg(test)] |
| 109 | mod tests { |
| 110 | use super::*; |
| 111 | use crate::localization::Locale; |
| 112 | |
| 113 | /// Every locale we ship translations for must be offered in the picker, |
| 114 | /// otherwise the footer advertises hotkeys that select nothing and users |
| 115 | /// can never reach a supported UI language (#3929). |
| 116 | #[test] |
| 117 | fn picker_offers_every_shipped_locale() { |
| 118 | let offered: Vec<&str> = LANGUAGE_OPTIONS.iter().map(|(_, tag, _, _)| *tag).collect(); |
| 119 | assert!( |
| 120 | offered.contains(&"auto"), |
| 121 | "picker must keep the auto-detect entry" |
| 122 | ); |
| 123 | for locale in Locale::shipped() { |
| 124 | let tag = locale.tag(); |
| 125 | assert!( |
| 126 | offered.contains(&tag), |
| 127 | "shipped locale {tag} is not offered in the language picker" |
| 128 | ); |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /// Hotkeys must be the contiguous run `1..=9` followed by contiguous |
| 133 | /// lowercase letters `a`, `b`, … so the footer hint stays truthful and |
| 134 | /// `KeyCode::Char` lookups resolve for every option. |
| 135 | #[test] |
| 136 | fn picker_hotkeys_are_contiguous_digits_then_letters() { |
| 137 | for (idx, (hotkey, tag, _, _)) in LANGUAGE_OPTIONS.iter().enumerate() { |
| 138 | let expected = if idx < 9 { |
| 139 | char::from_digit((idx + 1) as u32, 10).expect("digit") |
| 140 | } else { |
| 141 | char::from_u32('a' as u32 + (idx - 9) as u32).expect("letter") |
| 142 | }; |
| 143 | assert_eq!( |
| 144 | *hotkey, expected, |
| 145 | "option {tag} should use hotkey {expected}, not {hotkey}" |
| 146 | ); |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 |