| 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 | //! The screen appears only when the locale cannot be confidently inferred |
| 9 | //! (see `onboarding::locale_confidently_inferred`); most first runs never |
| 10 | //! see it. |
| 11 | |
| 12 | use ratatui::style::{Modifier, Style}; |
| 13 | use ratatui::text::{Line, Span}; |
| 14 | |
| 15 | use crate::tui::app::App; |
| 16 | use codewhale_localization::MessageId; |
| 17 | use codewhale_palette as palette; |
| 18 | use unicode_width::UnicodeWidthStr; |
| 19 | |
| 20 | /// Locale options shown in the picker. Order matches the keyboard hotkeys. |
| 21 | /// Each entry is `(hotkey, settings_tag, native_name, english_label)`. |
| 22 | /// `settings_tag` is what `Settings::set("locale", …)` accepts and what |
| 23 | /// `localization::Locale` resolves on next read. |
| 24 | /// |
| 25 | /// Hotkeys run `1..=9` then `a`, `b`, … so more than nine shipped locales |
| 26 | /// stay single-keystroke selectable. |
| 27 | pub const LANGUAGE_OPTIONS: &[(char, &str, &str, &str)] = &[ |
| 28 | ('1', "auto", "Auto-detect", "(LC_ALL / LANG)"), |
| 29 | ('2', "en", "English", ""), |
| 30 | ('3', "ja", "日本語", "(Japanese)"), |
| 31 | ('4', "zh-Hans", "简体中文", "(Simplified Chinese)"), |
| 32 | ('5', "zh-Hant", "繁體中文", "(Traditional Chinese)"), |
| 33 | ('6', "pt-BR", "Português (Brasil)", "(Brazilian Portuguese)"), |
| 34 | ( |
| 35 | '7', |
| 36 | "es-419", |
| 37 | "Español (Latinoamérica)", |
| 38 | "(Latin American Spanish)", |
| 39 | ), |
| 40 | ('8', "vi", "Tiếng Việt", "(Vietnamese)"), |
| 41 | ('9', "ko", "한국어", "(Korean)"), |
| 42 | ('a', "ca", "Català", "(Catalan)"), |
| 43 | ('b', "de", "Deutsch", "(German)"), |
| 44 | ('c', "fr", "Français", "(French)"), |
| 45 | ('d', "id", "Bahasa Indonesia", "(Indonesian)"), |
| 46 | ('e', "hi", "हिन्दी", "(Hindi)"), |
| 47 | ('f', "ru", "Русский", "(Russian)"), |
| 48 | ('g', "uk", "Українська", "(Ukrainian)"), |
| 49 | ]; |
| 50 | |
| 51 | /// Two columns keep every shipped locale visible at 80x24; below this width |
| 52 | /// the list falls back to one column and the English annotations return. |
| 53 | const TWO_COLUMN_MIN_WIDTH: usize = 56; |
| 54 | |
| 55 | pub fn lines(app: &App, width: usize, height: usize) -> Vec<Line<'static>> { |
| 56 | let current_owned = app.current_locale_tag(); |
| 57 | let current = current_owned.as_str(); |
| 58 | |
| 59 | let title = Line::from(Span::styled( |
| 60 | app.tr(MessageId::OnboardLanguageTitle).to_string(), |
| 61 | Style::default() |
| 62 | .fg(palette::WHALE_ACTION) |
| 63 | .add_modifier(Modifier::BOLD), |
| 64 | )); |
| 65 | |
| 66 | // The action rail can leave only five body rows at 40x12. A compact grid |
| 67 | // spends one on the title and derives enough columns to keep every |
| 68 | // selectable hotkey visible in the remaining rows. Wider/taller terminals |
| 69 | // keep the native names and explanatory sentence. |
| 70 | if height < 12 { |
| 71 | let option_rows = height.saturating_sub(1).max(1); |
| 72 | return compact_grid(title, width, option_rows, current); |
| 73 | } |
| 74 | |
| 75 | let mut out: Vec<Line<'static>> = vec![title, Line::from("")]; |
| 76 | for segment in super::wrap_words(&app.tr(MessageId::OnboardLanguageBlurb), width) { |
| 77 | out.push(Line::from(Span::styled( |
| 78 | segment, |
| 79 | Style::default().fg(palette::TEXT_PRIMARY), |
| 80 | ))); |
| 81 | } |
| 82 | out.push(Line::from("")); |
| 83 | |
| 84 | let two_column = width >= TWO_COLUMN_MIN_WIDTH; |
| 85 | if two_column { |
| 86 | let column_width = (width - 3) / 2; |
| 87 | let split = LANGUAGE_OPTIONS.len().div_ceil(2); |
| 88 | let (left_column, right_column) = LANGUAGE_OPTIONS.split_at(split); |
| 89 | for (idx, left) in left_column.iter().enumerate() { |
| 90 | let mut spans = option_spans(left, current); |
| 91 | if let Some(right) = right_column.get(idx) { |
| 92 | let used: usize = spans |
| 93 | .iter() |
| 94 | .map(|span| UnicodeWidthStr::width(span.content.as_ref())) |
| 95 | .sum(); |
| 96 | let gap = (column_width + 1).saturating_sub(used).max(2); |
| 97 | spans.push(Span::raw(" ".repeat(gap))); |
| 98 | spans.extend(option_spans(right, current)); |
| 99 | } |
| 100 | out.push(Line::from(spans)); |
| 101 | } |
| 102 | } else { |
| 103 | for option in LANGUAGE_OPTIONS { |
| 104 | out.push(Line::from(option_spans_with_english(option, current))); |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | out |
| 109 | } |
| 110 | |
| 111 | fn compact_grid( |
| 112 | title: Line<'static>, |
| 113 | width: usize, |
| 114 | rows: usize, |
| 115 | current: &str, |
| 116 | ) -> Vec<Line<'static>> { |
| 117 | let columns = LANGUAGE_OPTIONS.len().div_ceil(rows); |
| 118 | let gap = usize::from(columns > 1); |
| 119 | let column_width = width |
| 120 | .saturating_sub(gap.saturating_mul(columns.saturating_sub(1))) |
| 121 | .checked_div(columns) |
| 122 | .unwrap_or(1) |
| 123 | .max(1); |
| 124 | let mut out = vec![title]; |
| 125 | for row in 0..rows { |
| 126 | let mut spans = Vec::new(); |
| 127 | for column in 0..columns { |
| 128 | let index = row * columns + column; |
| 129 | let Some(option) = LANGUAGE_OPTIONS.get(index) else { |
| 130 | break; |
| 131 | }; |
| 132 | if column > 0 { |
| 133 | spans.push(Span::raw(" ".repeat(gap))); |
| 134 | } |
| 135 | spans.extend(compact_option_spans(option, current, column_width)); |
| 136 | } |
| 137 | out.push(Line::from(spans)); |
| 138 | } |
| 139 | out |
| 140 | } |
| 141 | |
| 142 | fn compact_option_spans( |
| 143 | option: &(char, &str, &str, &str), |
| 144 | current: &str, |
| 145 | width: usize, |
| 146 | ) -> Vec<Span<'static>> { |
| 147 | let (hotkey, tag, native, _) = *option; |
| 148 | let prefix = format!("[{hotkey}]"); |
| 149 | let prefix_width = UnicodeWidthStr::width(prefix.as_str()); |
| 150 | let label_width = width.saturating_sub(prefix_width); |
| 151 | let label = crate::tui::ui_text::semantic_truncate(native, label_width); |
| 152 | let style = if current == tag { |
| 153 | Style::default() |
| 154 | .fg(palette::WHALE_ACTION) |
| 155 | .add_modifier(Modifier::BOLD) |
| 156 | } else { |
| 157 | Style::default().fg(palette::TEXT_PRIMARY) |
| 158 | }; |
| 159 | let used = prefix_width + UnicodeWidthStr::width(label.as_str()); |
| 160 | vec![ |
| 161 | Span::styled(prefix, style), |
| 162 | Span::styled(label, style), |
| 163 | Span::raw(" ".repeat(width.saturating_sub(used))), |
| 164 | ] |
| 165 | } |
| 166 | |
| 167 | fn option_spans(option: &(char, &str, &str, &str), current: &str) -> Vec<Span<'static>> { |
| 168 | option_spans_inner(option, current, false) |
| 169 | } |
| 170 | |
| 171 | fn option_spans_with_english( |
| 172 | option: &(char, &str, &str, &str), |
| 173 | current: &str, |
| 174 | ) -> Vec<Span<'static>> { |
| 175 | option_spans_inner(option, current, true) |
| 176 | } |
| 177 | |
| 178 | fn option_spans_inner( |
| 179 | option: &(char, &str, &str, &str), |
| 180 | current: &str, |
| 181 | with_english: bool, |
| 182 | ) -> Vec<Span<'static>> { |
| 183 | let (hotkey, tag, native, english) = *option; |
| 184 | let is_current = current == tag; |
| 185 | let bullet = if is_current { |
| 186 | crate::tui::glyphs::CURRENT |
| 187 | } else { |
| 188 | crate::tui::glyphs::AVAILABLE |
| 189 | }; |
| 190 | let bullet_color = if is_current { |
| 191 | palette::WHALE_ACTION |
| 192 | } else { |
| 193 | palette::TEXT_MUTED |
| 194 | }; |
| 195 | let mut spans: Vec<Span<'static>> = vec![ |
| 196 | Span::styled(format!("{bullet} "), Style::default().fg(bullet_color)), |
| 197 | Span::styled( |
| 198 | format!("[{hotkey}] "), |
| 199 | Style::default() |
| 200 | .fg(palette::TEXT_PRIMARY) |
| 201 | .add_modifier(Modifier::BOLD), |
| 202 | ), |
| 203 | Span::styled( |
| 204 | native.to_string(), |
| 205 | Style::default().fg(palette::TEXT_PRIMARY), |
| 206 | ), |
| 207 | ]; |
| 208 | if with_english && !english.is_empty() { |
| 209 | spans.push(Span::styled( |
| 210 | format!(" {english}"), |
| 211 | Style::default().fg(palette::TEXT_MUTED), |
| 212 | )); |
| 213 | } |
| 214 | spans |
| 215 | } |
| 216 | |
| 217 | #[cfg(test)] |
| 218 | mod tests { |
| 219 | use super::*; |
| 220 | use crate::config::Config; |
| 221 | use crate::tui::app::TuiOptions; |
| 222 | use codewhale_localization::Locale; |
| 223 | use std::path::PathBuf; |
| 224 | |
| 225 | fn app() -> App { |
| 226 | let options = TuiOptions { |
| 227 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 228 | }; |
| 229 | let mut app = App::new(options, &Config::default()); |
| 230 | app.ui_locale = Locale::En; |
| 231 | app |
| 232 | } |
| 233 | |
| 234 | fn row_text(line: &Line<'static>) -> String { |
| 235 | line.spans |
| 236 | .iter() |
| 237 | .map(|span| span.content.as_ref()) |
| 238 | .collect::<String>() |
| 239 | } |
| 240 | |
| 241 | /// Every locale we ship translations for must be offered in the picker, |
| 242 | /// otherwise the footer advertises hotkeys that select nothing and users |
| 243 | /// can never reach a supported UI language (#3929). |
| 244 | #[test] |
| 245 | fn picker_offers_every_shipped_locale() { |
| 246 | let offered: Vec<&str> = LANGUAGE_OPTIONS.iter().map(|(_, tag, _, _)| *tag).collect(); |
| 247 | assert!( |
| 248 | offered.contains(&"auto"), |
| 249 | "picker must keep the auto-detect entry" |
| 250 | ); |
| 251 | for locale in Locale::shipped() { |
| 252 | let tag = locale.tag(); |
| 253 | assert!( |
| 254 | offered.contains(&tag), |
| 255 | "shipped locale {tag} is not offered in the language picker" |
| 256 | ); |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | /// Hotkeys must be the contiguous run `1..=9` followed by contiguous |
| 261 | /// lowercase letters `a`, `b`, … so the footer hint stays truthful and |
| 262 | /// `KeyCode::Char` lookups resolve for every option. |
| 263 | #[test] |
| 264 | fn picker_hotkeys_are_contiguous_digits_then_letters() { |
| 265 | for (idx, (hotkey, tag, _, _)) in LANGUAGE_OPTIONS.iter().enumerate() { |
| 266 | let expected = if idx < 9 { |
| 267 | char::from_digit((idx + 1) as u32, 10).expect("digit") |
| 268 | } else { |
| 269 | char::from_u32('a' as u32 + (idx - 9) as u32).expect("letter") |
| 270 | }; |
| 271 | assert_eq!( |
| 272 | *hotkey, expected, |
| 273 | "option {tag} should use hotkey {expected}, not {hotkey}" |
| 274 | ); |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | /// At 80 columns the surface body is ~72 wide, so every option must stay |
| 279 | /// on one row and the whole list must fit a 24-row terminal. |
| 280 | #[test] |
| 281 | fn every_option_is_visible_at_80_columns() { |
| 282 | let rows = lines(&app(), 72, 17); |
| 283 | let text = rows.iter().map(row_text).collect::<Vec<_>>(); |
| 284 | |
| 285 | for (_, tag, native, _) in LANGUAGE_OPTIONS { |
| 286 | let shown = text.iter().any(|row| row.contains(native)); |
| 287 | assert!(shown, "option {tag} ({native}) missing from the picker"); |
| 288 | } |
| 289 | // 17 options in 9 rows + 5 header rows fits the ~17 usable rows of a |
| 290 | // 24-line terminal under the onboarding surface. |
| 291 | assert!( |
| 292 | rows.len() <= 15, |
| 293 | "picker is {} rows; it must fit 80x24", |
| 294 | rows.len() |
| 295 | ); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn compact_grid_keeps_every_language_hotkey_reachable() { |
| 300 | let rows = lines(&app(), 36, 5); |
| 301 | let text = rows.iter().map(row_text).collect::<Vec<_>>().join("\n"); |
| 302 | |
| 303 | assert_eq!(rows.len(), 5, "one title plus four option rows"); |
| 304 | for (hotkey, tag, _, _) in LANGUAGE_OPTIONS { |
| 305 | assert!(text.contains(&format!("[{hotkey}]")), "missing {tag}"); |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 |