herogpui_components/i18n.rs
1//! Localizable component chrome strings (HeroGPUI extension).
2//!
3//! Dates, times and numbers already format per locale through ICU4X (each
4//! picker's `.locale(..)`). This module covers the *fixed* strings the
5//! components draw or announce themselves: the Select placeholder, the
6//! calendar's Previous/Next buttons, the CloseButton's name, the NumberField
7//! stepper names, the date and time segment names, the colour channel
8//! names, and so on.
9//!
10//! The application picks one locale with [`set_locale`]; every string then
11//! resolves through [`ui_string`] as: an application override
12//! ([`set_ui_string`]) → the built-in table for the locale (exact tag, then
13//! its language) → en-US. Without any call, every component renders the same
14//! en-US text it always has.
15//!
16//! The built-in translations are copied from the dictionaries HeroUI v3.2.6
17//! pins, which is where HeroUI's own strings come from: `react-aria` 3.52.1
18//! `dist/private/intl/<package>/<locale>.mjs`, `react-stately` 3.50.0
19//! `dist/private/intl/color/<locale>.mjs` (the colour channel names) and
20//! `react-aria-components` 1.21.1 `dist/private/intl/<locale>.mjs`. Six
21//! strings HeroUI hard-codes in English with no dictionary entry
22//! ([`UiString::NoResults`], [`UiString::Loading`], [`UiString::Search`],
23//! [`UiString::ClearSelection`], [`UiString::Pagination`],
24//! [`UiString::LoadingMore`]) carry HeroGPUI's own translations; reword any
25//! of them with [`set_ui_string`].
26//!
27//! A few strings are templates with one `{..}` placeholder, as in React
28//! Aria ([`UiString::Increase`] is `"Increase {fieldLabel}"`, German
29//! `"{fieldLabel} erhöhen"`); [`ui_string_with`] fills it.
30//!
31//! ```
32//! use herogpui_components::i18n::{self, UiString};
33//!
34//! assert_eq!(i18n::lookup("de-DE", UiString::Next), "Weiter");
35//! // A region the table lacks falls back to its language...
36//! assert_eq!(i18n::lookup("de-AT", UiString::Next), "Weiter");
37//! // ...and an unknown language to en-US.
38//! assert_eq!(i18n::lookup("xx", UiString::Next), "Next");
39//! assert_eq!(i18n::lookup("ja", UiString::Close), "閉じる");
40//! assert_eq!(i18n::fill(i18n::lookup("de", UiString::Increase), "Menge"), "Menge erhöhen");
41//! ```
42
43use std::collections::HashMap;
44
45use gpui::{App, Global, SharedString};
46
47/// A component chrome string. See the [module docs](self).
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49#[non_exhaustive]
50pub enum UiString {
51 /// Select / Autocomplete placeholder (`selectPlaceholder`, RAC).
52 SelectPlaceholder,
53 /// Autocomplete's empty-collection message (HeroUI; HeroGPUI translations).
54 NoResults,
55 /// CloseButton's accessible name (`close`, React Aria toast).
56 Close,
57 /// Spinner's accessible name (HeroUI; HeroGPUI translations).
58 Loading,
59 /// Calendar previous-page button (`previous`, React Aria calendar).
60 Previous,
61 /// Calendar next-page button (`next`, React Aria calendar).
62 Next,
63 /// Tag remove button (`removeButtonLabel`, React Aria tag).
64 Remove,
65 /// Breadcrumbs list name (`breadcrumbs`, React Aria breadcrumbs).
66 Breadcrumbs,
67 /// SearchField placeholder (HeroUI; HeroGPUI translations).
68 Search,
69 /// NumberField / TimeField increment stepper, a `{fieldLabel}` template
70 /// (`increase`, React Aria numberfield).
71 Increase,
72 /// NumberField / TimeField decrement stepper, a `{fieldLabel}` template
73 /// (`decrease`, React Aria numberfield).
74 Decrease,
75 /// The year segment's name (`year`, React Aria datepicker).
76 Year,
77 /// The month segment's name (`month`, React Aria datepicker).
78 Month,
79 /// The day segment's name (`day`, React Aria datepicker).
80 Day,
81 /// The hour segment's name (`hour`, React Aria datepicker).
82 Hour,
83 /// The minute segment's name (`minute`, React Aria datepicker).
84 Minute,
85 /// The second segment's name (`second`, React Aria datepicker).
86 Second,
87 /// The AM/PM segment's name (`dayPeriod`, React Aria datepicker).
88 DayPeriod,
89 /// The DatePicker calendar button (`calendar`, React Aria datepicker).
90 Calendar,
91 /// A selected calendar day, a `{date}` template (`dateSelected`, React
92 /// Aria calendar).
93 DateSelected,
94 /// The hue channel (`hue`, React Stately color).
95 Hue,
96 /// The saturation channel (`saturation`, React Stately color).
97 Saturation,
98 /// The HSL lightness channel (`lightness`, React Stately color).
99 Lightness,
100 /// The HSB brightness channel (`brightness`, React Stately color).
101 Brightness,
102 /// The red channel (`red`, React Stately color).
103 Red,
104 /// The green channel (`green`, React Stately color).
105 Green,
106 /// The blue channel (`blue`, React Stately color).
107 Blue,
108 /// The alpha channel (`alpha`, React Stately color).
109 Alpha,
110 /// Autocomplete's clear button (HeroUI; HeroGPUI translations).
111 ClearSelection,
112 /// Pagination's navigation name (HeroUI; HeroGPUI translations).
113 Pagination,
114 /// Table's load-more row text while `is_pending` (HeroUI; HeroGPUI
115 /// translations: [`UiString::Loading`] with an ellipsis).
116 LoadingMore,
117}
118
119impl UiString {
120 /// The number of keys.
121 pub const COUNT: usize = 31;
122
123 /// Every key, in declaration order.
124 pub const ALL: [UiString; UiString::COUNT] = [
125 UiString::SelectPlaceholder,
126 UiString::NoResults,
127 UiString::Close,
128 UiString::Loading,
129 UiString::Previous,
130 UiString::Next,
131 UiString::Remove,
132 UiString::Breadcrumbs,
133 UiString::Search,
134 UiString::Increase,
135 UiString::Decrease,
136 UiString::Year,
137 UiString::Month,
138 UiString::Day,
139 UiString::Hour,
140 UiString::Minute,
141 UiString::Second,
142 UiString::DayPeriod,
143 UiString::Calendar,
144 UiString::DateSelected,
145 UiString::Hue,
146 UiString::Saturation,
147 UiString::Lightness,
148 UiString::Brightness,
149 UiString::Red,
150 UiString::Green,
151 UiString::Blue,
152 UiString::Alpha,
153 UiString::ClearSelection,
154 UiString::Pagination,
155 UiString::LoadingMore,
156 ];
157
158 fn index(self) -> usize {
159 self as usize
160 }
161}
162
163/// The locales with built-in translations; en-US first.
164pub const LOCALES: [&str; 13] = [
165 "en-US", "de-DE", "es-ES", "fr-FR", "it-IT", "nl-NL", "pl-PL", "pt-BR", "sv-SE", "ja-JP",
166 "zh-CN", "ko-KR", "ru-RU",
167];
168
169const EN: [&str; UiString::COUNT] = [
170 "Select an item", // SelectPlaceholder
171 "No results found", // NoResults
172 "Close", // Close
173 "Loading", // Loading
174 "Previous", // Previous
175 "Next", // Next
176 "Remove", // Remove
177 "Breadcrumbs", // Breadcrumbs
178 "Search", // Search
179 "Increase {fieldLabel}", // Increase
180 "Decrease {fieldLabel}", // Decrease
181 "year", // Year
182 "month", // Month
183 "day", // Day
184 "hour", // Hour
185 "minute", // Minute
186 "second", // Second
187 "AM/PM", // DayPeriod
188 "Calendar", // Calendar
189 "{date} selected", // DateSelected
190 "Hue", // Hue
191 "Saturation", // Saturation
192 "Lightness", // Lightness
193 "Brightness", // Brightness
194 "Red", // Red
195 "Green", // Green
196 "Blue", // Blue
197 "Alpha", // Alpha
198 "Clear selection", // ClearSelection
199 "pagination", // Pagination
200 "Loading\u{2026}", // LoadingMore
201];
202
203const TABLE: [[&str; UiString::COUNT]; 12] = [
204 // de-DE
205 [
206 "Element wählen", // SelectPlaceholder
207 "Keine Ergebnisse gefunden", // NoResults
208 "Schließen", // Close
209 "Wird geladen", // Loading
210 "Zurück", // Previous
211 "Weiter", // Next
212 "Entfernen", // Remove
213 "Breadcrumbs", // Breadcrumbs
214 "Suchen", // Search
215 "{fieldLabel} erhöhen", // Increase
216 "{fieldLabel} verringern", // Decrease
217 "Jahr", // Year
218 "Monat", // Month
219 "Tag", // Day
220 "Stunde", // Hour
221 "Minute", // Minute
222 "Sekunde", // Second
223 "Tageshälfte", // DayPeriod
224 "Kalender", // Calendar
225 "{date} ausgewählt", // DateSelected
226 "Farbton", // Hue
227 "Sättigung", // Saturation
228 "Leuchtkraft", // Lightness
229 "Helligkeit", // Brightness
230 "Rot", // Red
231 "Grün", // Green
232 "Blau", // Blue
233 "Alpha", // Alpha
234 "Auswahl löschen", // ClearSelection
235 "Seitennummerierung", // Pagination
236 "Wird geladen…", // LoadingMore
237 ],
238 // es-ES
239 [
240 "Seleccionar un artículo", // SelectPlaceholder
241 "No se encontraron resultados", // NoResults
242 "Cerrar", // Close
243 "Cargando", // Loading
244 "Anterior", // Previous
245 "Siguiente", // Next
246 "Quitar", // Remove
247 "Migas de pan", // Breadcrumbs
248 "Buscar", // Search
249 "Aumentar {fieldLabel}", // Increase
250 "Reducir {fieldLabel}", // Decrease
251 "año", // Year
252 "mes", // Month
253 "día", // Day
254 "hora", // Hour
255 "minuto", // Minute
256 "segundo", // Second
257 "a.\u{a0}m./p.\u{a0}m.", // DayPeriod
258 "Calendario", // Calendar
259 "{date} seleccionado", // DateSelected
260 "Tono", // Hue
261 "Saturación", // Saturation
262 "Luminosidad", // Lightness
263 "Brillo", // Brightness
264 "Rojo", // Red
265 "Verde", // Green
266 "Azul", // Blue
267 "Alpha", // Alpha
268 "Borrar selección", // ClearSelection
269 "paginación", // Pagination
270 "Cargando…", // LoadingMore
271 ],
272 // fr-FR
273 [
274 "Sélectionner un élément", // SelectPlaceholder
275 "Aucun résultat trouvé", // NoResults
276 "Fermer", // Close
277 "Chargement", // Loading
278 "Précédent", // Previous
279 "Suivant", // Next
280 "Supprimer", // Remove
281 "Chemin de navigation", // Breadcrumbs
282 "Rechercher", // Search
283 "Augmenter {fieldLabel}", // Increase
284 "Diminuer {fieldLabel}", // Decrease
285 "année", // Year
286 "mois", // Month
287 "jour", // Day
288 "heure", // Hour
289 "minute", // Minute
290 "seconde", // Second
291 "cadran", // DayPeriod
292 "Calendrier", // Calendar
293 "{date} sélectionné", // DateSelected
294 "Teinte", // Hue
295 "Saturation", // Saturation
296 "Luminosité", // Lightness
297 "Luminosité", // Brightness
298 "Rouge", // Red
299 "Vert", // Green
300 "Bleu", // Blue
301 "Alpha", // Alpha
302 "Effacer la sélection", // ClearSelection
303 "pagination", // Pagination
304 "Chargement…", // LoadingMore
305 ],
306 // it-IT
307 [
308 "Seleziona un elemento", // SelectPlaceholder
309 "Nessun risultato trovato", // NoResults
310 "Chiudi", // Close
311 "Caricamento", // Loading
312 "Precedente", // Previous
313 "Successivo", // Next
314 "Rimuovi", // Remove
315 "Breadcrumb", // Breadcrumbs
316 "Cerca", // Search
317 "Aumenta {fieldLabel}", // Increase
318 "Riduci {fieldLabel}", // Decrease
319 "anno", // Year
320 "mese", // Month
321 "giorno", // Day
322 "ora", // Hour
323 "minuto", // Minute
324 "secondo", // Second
325 "AM/PM", // DayPeriod
326 "Calendario", // Calendar
327 "{date} selezionata", // DateSelected
328 "Tonalità", // Hue
329 "Saturazione", // Saturation
330 "Luminosità", // Lightness
331 "Luminosità", // Brightness
332 "Rosso", // Red
333 "Verde", // Green
334 "Blu", // Blue
335 "Alfa", // Alpha
336 "Cancella selezione", // ClearSelection
337 "paginazione", // Pagination
338 "Caricamento…", // LoadingMore
339 ],
340 // nl-NL
341 [
342 "Selecteer een item", // SelectPlaceholder
343 "Geen resultaten gevonden", // NoResults
344 "Sluiten", // Close
345 "Laden", // Loading
346 "Vorige", // Previous
347 "Volgende", // Next
348 "Verwijderen", // Remove
349 "Broodkruimels", // Breadcrumbs
350 "Zoeken", // Search
351 "{fieldLabel} verhogen", // Increase
352 "{fieldLabel} verlagen", // Decrease
353 "jaar", // Year
354 "maand", // Month
355 "dag", // Day
356 "uur", // Hour
357 "minuut", // Minute
358 "seconde", // Second
359 "a.m./p.m.", // DayPeriod
360 "Kalender", // Calendar
361 "{date} geselecteerd", // DateSelected
362 "Kleurtoon", // Hue
363 "Verzadiging", // Saturation
364 "Lichtsterkte", // Lightness
365 "Helderheid", // Brightness
366 "Rood", // Red
367 "Groen", // Green
368 "Blauw", // Blue
369 "Alfa", // Alpha
370 "Selectie wissen", // ClearSelection
371 "paginering", // Pagination
372 "Laden…", // LoadingMore
373 ],
374 // pl-PL
375 [
376 "Wybierz element", // SelectPlaceholder
377 "Nie znaleziono wyników", // NoResults
378 "Zamknij", // Close
379 "Ładowanie", // Loading
380 "Wstecz", // Previous
381 "Dalej", // Next
382 "Usuń", // Remove
383 "Struktura nawigacyjna", // Breadcrumbs
384 "Szukaj", // Search
385 "Zwiększ {fieldLabel}", // Increase
386 "Zmniejsz {fieldLabel}", // Decrease
387 "rok", // Year
388 "miesiąc", // Month
389 "dzień", // Day
390 "godzina", // Hour
391 "minuta", // Minute
392 "sekunda", // Second
393 "rano / po południu / wieczorem", // DayPeriod
394 "Kalendarz", // Calendar
395 "Wybrano {date}", // DateSelected
396 "Odcień", // Hue
397 "Nasycenie", // Saturation
398 "Jaskrawość", // Lightness
399 "Jasność", // Brightness
400 "Czerwony", // Red
401 "Zielony", // Green
402 "Niebieski", // Blue
403 "Alfa", // Alpha
404 "Wyczyść zaznaczenie", // ClearSelection
405 "paginacja", // Pagination
406 "Ładowanie…", // LoadingMore
407 ],
408 // pt-BR
409 [
410 "Selecione um item", // SelectPlaceholder
411 "Nenhum resultado encontrado", // NoResults
412 "Fechar", // Close
413 "Carregando", // Loading
414 "Anterior", // Previous
415 "Próximo", // Next
416 "Remover", // Remove
417 "Caminho detalhado", // Breadcrumbs
418 "Pesquisar", // Search
419 "Aumentar {fieldLabel}", // Increase
420 "Diminuir {fieldLabel}", // Decrease
421 "ano", // Year
422 "mês", // Month
423 "dia", // Day
424 "hora", // Hour
425 "minuto", // Minute
426 "segundo", // Second
427 "AM/PM", // DayPeriod
428 "Calendário", // Calendar
429 "{date} selecionado", // DateSelected
430 "Matiz", // Hue
431 "Saturação", // Saturation
432 "Luminosidade", // Lightness
433 "Brilho", // Brightness
434 "Vermelho", // Red
435 "Verde", // Green
436 "Azul", // Blue
437 "Alfa", // Alpha
438 "Limpar seleção", // ClearSelection
439 "paginação", // Pagination
440 "Carregando…", // LoadingMore
441 ],
442 // sv-SE
443 [
444 "Välj en artikel", // SelectPlaceholder
445 "Inga resultat hittades", // NoResults
446 "Stäng", // Close
447 "Läser in", // Loading
448 "Föregående", // Previous
449 "Nästa", // Next
450 "Ta bort", // Remove
451 "Sökvägar", // Breadcrumbs
452 "Sök", // Search
453 "Öka {fieldLabel}", // Increase
454 "Minska {fieldLabel}", // Decrease
455 "år", // Year
456 "månad", // Month
457 "dag", // Day
458 "timme", // Hour
459 "minut", // Minute
460 "sekund", // Second
461 "fm/em", // DayPeriod
462 "Kalender", // Calendar
463 "{date} har valts", // DateSelected
464 "Nyans", // Hue
465 "Mättnad", // Saturation
466 "Ljushet", // Lightness
467 "Ljusstyrka", // Brightness
468 "Rött", // Red
469 "Grönt", // Green
470 "Blått", // Blue
471 "Alfa", // Alpha
472 "Rensa urval", // ClearSelection
473 "sidnumrering", // Pagination
474 "Läser in…", // LoadingMore
475 ],
476 // ja-JP
477 [
478 "項目を選択", // SelectPlaceholder
479 "結果が見つかりません", // NoResults
480 "閉じる", // Close
481 "読み込み中", // Loading
482 "前へ", // Previous
483 "次へ", // Next
484 "削除", // Remove
485 "パンくずリスト", // Breadcrumbs
486 "検索", // Search
487 "{fieldLabel}を拡大", // Increase
488 "{fieldLabel}を縮小", // Decrease
489 "年", // Year
490 "月", // Month
491 "日", // Day
492 "時", // Hour
493 "分", // Minute
494 "秒", // Second
495 "午前/午後", // DayPeriod
496 "カレンダー", // Calendar
497 "{date} を選択", // DateSelected
498 "色相", // Hue
499 "彩度", // Saturation
500 "明度", // Lightness
501 "明るさ", // Brightness
502 "赤", // Red
503 "緑", // Green
504 "青", // Blue
505 "アルファ", // Alpha
506 "選択をクリア", // ClearSelection
507 "ページネーション", // Pagination
508 "読み込み中…", // LoadingMore
509 ],
510 // zh-CN
511 [
512 "选择一个项目", // SelectPlaceholder
513 "未找到结果", // NoResults
514 "关闭", // Close
515 "加载中", // Loading
516 "上一页", // Previous
517 "下一页", // Next
518 "删除", // Remove
519 "导航栏", // Breadcrumbs
520 "搜索", // Search
521 "提高 {fieldLabel}", // Increase
522 "降低 {fieldLabel}", // Decrease
523 "年", // Year
524 "月", // Month
525 "日", // Day
526 "小时", // Hour
527 "分钟", // Minute
528 "秒", // Second
529 "上午/下午", // DayPeriod
530 "日历", // Calendar
531 "已选择 {date}", // DateSelected
532 "色相", // Hue
533 "饱和度", // Saturation
534 "明亮度", // Lightness
535 "亮度", // Brightness
536 "红色", // Red
537 "绿色", // Green
538 "蓝色", // Blue
539 "Alpha", // Alpha
540 "清除选择", // ClearSelection
541 "分页", // Pagination
542 "加载中…", // LoadingMore
543 ],
544 // ko-KR
545 [
546 "항목 선택", // SelectPlaceholder
547 "결과를 찾을 수 없습니다", // NoResults
548 "닫기", // Close
549 "로드 중", // Loading
550 "이전", // Previous
551 "다음", // Next
552 "제거", // Remove
553 "탐색 표시", // Breadcrumbs
554 "검색", // Search
555 "{fieldLabel} 증가", // Increase
556 "{fieldLabel} 감소", // Decrease
557 "년", // Year
558 "월", // Month
559 "일", // Day
560 "시", // Hour
561 "분", // Minute
562 "초", // Second
563 "오전/오후", // DayPeriod
564 "달력", // Calendar
565 "{date} 선택됨", // DateSelected
566 "색조", // Hue
567 "채도", // Saturation
568 "밝기", // Lightness
569 "명도", // Brightness
570 "빨강", // Red
571 "초록", // Green
572 "파랑", // Blue
573 "알파", // Alpha
574 "선택 지우기", // ClearSelection
575 "페이지 매김", // Pagination
576 "로드 중…", // LoadingMore
577 ],
578 // ru-RU
579 [
580 "Выберите элемент", // SelectPlaceholder
581 "Ничего не найдено", // NoResults
582 "Закрыть", // Close
583 "Загрузка", // Loading
584 "Назад", // Previous
585 "Далее", // Next
586 "Удалить", // Remove
587 "Навигация", // Breadcrumbs
588 "Поиск", // Search
589 "Увеличение {fieldLabel}", // Increase
590 "Уменьшение {fieldLabel}", // Decrease
591 "год", // Year
592 "месяц", // Month
593 "день", // Day
594 "час", // Hour
595 "минута", // Minute
596 "секунда", // Second
597 "AM/PM", // DayPeriod
598 "Календарь", // Calendar
599 "Выбрано {date}", // DateSelected
600 "Оттенок", // Hue
601 "Насыщенность", // Saturation
602 "Освещенность", // Lightness
603 "Яркость", // Brightness
604 "Красный", // Red
605 "Зеленый", // Green
606 "Синий", // Blue
607 "Альфа", // Alpha
608 "Очистить выбор", // ClearSelection
609 "нумерация страниц", // Pagination
610 "Загрузка…", // LoadingMore
611 ],
612];
613
614fn language(tag: &str) -> &str {
615 tag.split(['-', '_']).next().unwrap_or(tag)
616}
617
618/// Resolves `tag` to one of [`LOCALES`]: the exact tag (case-insensitive,
619/// `_` accepted for `-`), else the first built-in locale with the same
620/// language, else `"en-US"`.
621pub fn resolve_locale(tag: &str) -> &'static str {
622 let normalized = tag.replace('_', "-");
623 if let Some(exact) = LOCALES.iter().find(|l| l.eq_ignore_ascii_case(&normalized)) {
624 return exact;
625 }
626 let lang = language(&normalized);
627 LOCALES
628 .iter()
629 .find(|l| language(l).eq_ignore_ascii_case(lang))
630 .copied()
631 .unwrap_or("en-US")
632}
633
634/// The built-in string for `key` in `locale`, with the fallbacks
635/// [`resolve_locale`] describes; en-US where the table has no entry.
636/// Ignores application overrides; see [`ui_string`] for those.
637pub fn lookup(locale: &str, key: UiString) -> &'static str {
638 let resolved = resolve_locale(locale);
639 LOCALES
640 .iter()
641 .position(|l| *l == resolved)
642 .and_then(|row| row.checked_sub(1))
643 .map_or(EN[key.index()], |row| TABLE[row][key.index()])
644}
645
646/// Fills a template's one `{..}` placeholder with `arg`, trimming the
647/// space an empty `arg` leaves (`"Increase {fieldLabel}"` with no label is
648/// `"Increase"`). A string without a placeholder comes back unchanged.
649pub fn fill(template: &str, arg: &str) -> String {
650 match template
651 .find('{')
652 .and_then(|open| Some((open, open + template[open..].find('}')?)))
653 {
654 Some((open, close)) => {
655 let filled = format!("{}{arg}{}", &template[..open], &template[close + 1..]);
656 filled.trim().to_owned()
657 }
658 None => template.to_owned(),
659 }
660}
661
662/// The application's string settings: the active locale and any overrides.
663/// A GPUI global; absent means en-US with no overrides.
664#[derive(Default)]
665struct UiStrings {
666 locale: Option<SharedString>,
667 overrides: HashMap<(SharedString, UiString), SharedString>,
668}
669
670impl Global for UiStrings {}
671
672/// Sets the locale component chrome strings resolve in, and repaints.
673/// Accepts any BCP 47 tag; see [`resolve_locale`] for the fallback.
674pub fn set_locale(locale: impl Into<SharedString>, cx: &mut App) {
675 cx.default_global::<UiStrings>().locale = Some(locale.into());
676 cx.refresh_windows();
677}
678
679/// The locale set with [`set_locale`], or `"en-US"`.
680pub fn locale(cx: &App) -> SharedString {
681 cx.try_global::<UiStrings>()
682 .and_then(|s| s.locale.clone())
683 .unwrap_or_else(|| SharedString::new_static("en-US"))
684}
685
686/// Overrides `key` for `locale`. An override keyed by a full tag (`"fr-CA"`)
687/// applies to that tag only; one keyed by a bare language (`"fr"`) applies to
688/// every locale of that language without an exact override. Use it to translate a string the built-in table
689/// lacks, or to reword one.
690pub fn set_ui_string(
691 locale: impl Into<SharedString>,
692 key: UiString,
693 value: impl Into<SharedString>,
694 cx: &mut App,
695) {
696 cx.default_global::<UiStrings>()
697 .overrides
698 .insert((locale.into(), key), value.into());
699 cx.refresh_windows();
700}
701
702/// The string for `key` in the active locale: an override, else the
703/// built-in table, else en-US.
704pub fn ui_string(key: UiString, cx: &App) -> SharedString {
705 let Some(strings) = cx.try_global::<UiStrings>() else {
706 return SharedString::new_static(EN[key.index()]);
707 };
708 let active = strings
709 .locale
710 .clone()
711 .unwrap_or_else(|| SharedString::new_static("en-US"));
712 let lang = language(&active);
713 let found = strings
714 .overrides
715 .get(&(active.clone(), key))
716 .or_else(|| {
717 strings
718 .overrides
719 .get(&(SharedString::from(lang.to_owned()), key))
720 })
721 .cloned();
722 found.unwrap_or_else(|| SharedString::new_static(lookup(&active, key)))
723}
724
725/// [`ui_string`] with its `{..}` placeholder filled with `arg` (see
726/// [`fill`]); the same as [`ui_string`] for a key that is not a template.
727pub fn ui_string_with(key: UiString, arg: &str, cx: &App) -> SharedString {
728 fill(&ui_string(key, cx), arg).into()
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 #[test]
736 fn en_us_matches_the_strings_components_drew_before() {
737 for key in UiString::ALL {
738 assert_eq!(lookup("en-US", key), EN[key.index()]);
739 }
740 assert_eq!(
741 lookup("en-US", UiString::SelectPlaceholder),
742 "Select an item"
743 );
744 }
745
746 #[test]
747 fn every_locale_resolves_every_key() {
748 for locale in LOCALES {
749 for key in UiString::ALL {
750 assert!(!lookup(locale, key).is_empty(), "{locale} {key:?}");
751 }
752 }
753 }
754
755 #[test]
756 fn fallback_chain() {
757 assert_eq!(resolve_locale("fr_CA"), "fr-FR");
758 assert_eq!(resolve_locale("PT-pt"), "pt-BR");
759 assert_eq!(resolve_locale("ja"), "ja-JP");
760 assert_eq!(resolve_locale("zh-Hans-CN"), "zh-CN");
761 assert_eq!(resolve_locale("tr-TR"), "en-US");
762 assert_eq!(lookup("sv-SE", UiString::Previous), "Föregående");
763 }
764
765 /// The new locales carry every key in their own script, spot-checked
766 /// against the pinned React Aria / React Stately dictionaries.
767 #[test]
768 fn the_non_latin_locales_are_translated() {
769 assert_eq!(lookup("ja-JP", UiString::SelectPlaceholder), "項目を選択");
770 assert_eq!(lookup("zh-CN", UiString::Next), "下一页");
771 assert_eq!(lookup("ko-KR", UiString::Close), "닫기");
772 assert_eq!(lookup("ru-RU", UiString::Hue), "Оттенок");
773 for locale in ["ja-JP", "zh-CN", "ko-KR", "ru-RU"] {
774 for key in UiString::ALL {
775 // The dictionaries keep these two in Latin script.
776 if matches!(
777 (locale, key),
778 ("zh-CN", UiString::Alpha) | ("ru-RU", UiString::DayPeriod)
779 ) {
780 continue;
781 }
782 assert_ne!(
783 lookup(locale, key),
784 lookup("en-US", key),
785 "{locale} {key:?}"
786 );
787 }
788 }
789 }
790
791 /// Table's pending row reads the Spinner's word with an ellipsis in every
792 /// locale, so the two HeroGPUI-owned strings cannot drift apart.
793 #[test]
794 fn loading_more_is_the_loading_word_with_an_ellipsis() {
795 for locale in LOCALES {
796 assert_eq!(
797 lookup(locale, UiString::LoadingMore),
798 format!("{}\u{2026}", lookup(locale, UiString::Loading)),
799 "{locale}"
800 );
801 }
802 assert_eq!(lookup("en-US", UiString::LoadingMore), "Loading\u{2026}");
803 }
804
805 #[test]
806 fn templates_fill_their_placeholder_in_the_locale_word_order() {
807 assert_eq!(
808 fill(lookup("en-US", UiString::Increase), "Quantity"),
809 "Increase Quantity"
810 );
811 assert_eq!(fill(lookup("en-US", UiString::Decrease), ""), "Decrease");
812 assert_eq!(
813 fill(lookup("de-DE", UiString::Decrease), "Menge"),
814 "Menge verringern"
815 );
816 assert_eq!(fill(lookup("ja-JP", UiString::Increase), ""), "を拡大");
817 assert_eq!(
818 fill(lookup("pl-PL", UiString::DateSelected), "1 maja"),
819 "Wybrano 1 maja"
820 );
821 assert_eq!(fill("Close", "ignored"), "Close");
822 // Exactly the keys React Aria formats with an argument are templates.
823 for key in UiString::ALL {
824 let template = matches!(
825 key,
826 UiString::Increase | UiString::Decrease | UiString::DateSelected
827 );
828 for locale in LOCALES {
829 assert_eq!(
830 lookup(locale, key).contains('{'),
831 template,
832 "{locale} {key:?}"
833 );
834 }
835 }
836 }
837}