Skip to main content

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}