返回 CodeWhale
language.rs
根目录 / crates / tui / src / tui / onboarding / language.rs
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
309 lines RUST