Skip to main content

gpui_kit/
strings.rs

1//! The words this library puts on screen, and the host's right to replace
2//! them.
3//!
4//! Components never hold English. They hold a [`StringKey`], and ask the
5//! application context for the text behind it at render time, exactly the way
6//! they ask for a colour:
7//!
8//! ```no_run
9//! # use gpui_kit::strings::{ActiveStrings, StringKey};
10//! # fn example(cx: &gpui::App) -> gpui::SharedString {
11//! cx.strings().text(StringKey::Copy)
12//! # }
13//! ```
14//!
15//! Every key carries an English default compiled into the binary, so a host
16//! that supplies nothing still gets a working, readable interface. A host that
17//! supplies some keys gets its own words for those and English for the rest;
18//! there is no state in which a label renders empty.
19//!
20//! Strings with a value in them are templates over positional placeholders —
21//! `{0}`, `{1}` — rather than Rust format strings, because a translation is
22//! allowed to put the value somewhere else in the sentence:
23//!
24//! ```no_run
25//! # use gpui_kit::strings::{ActiveStrings, StringKey};
26//! # fn example(cx: &gpui::App, query: &str) -> gpui::SharedString {
27//! cx.strings().format(StringKey::PaletteNoMatch, &[query])
28//! # }
29//! ```
30//!
31//! # What is not here
32//!
33//! Numbers, dates, and quantities are not translated. A count is still
34//! rendered with Rust's own digits and a plural is still chosen by an
35//! `if count == 1` at the call site, which is correct for English and for
36//! nothing else. `docs/coverage.md` records that as a named gap.
37
38use std::collections::BTreeMap;
39use std::sync::OnceLock;
40
41use gpui::{App, BorrowAppContext, Global, SharedString};
42
43/// Declares every key once: the variant, the stable name a host and a test
44/// use to address it, and the English a host gets for free.
45macro_rules! string_keys {
46    ($( $variant:ident => $name:literal, $default:literal ; )*) => {
47        /// Every piece of text this library can put on a screen.
48        ///
49        /// The set is closed and exhaustive: a component that needs a new word
50        /// adds a variant here, which is what makes a missing translation a
51        /// compile-time question rather than a runtime blank.
52        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
53        #[non_exhaustive]
54        pub enum StringKey {
55            $( #[doc = $default] $variant, )*
56        }
57
58        impl StringKey {
59            /// Every key, in declaration order.
60            pub const ALL: &'static [StringKey] = &[ $( StringKey::$variant, )* ];
61
62            /// The stable name a host configuration or a test uses.
63            ///
64            /// It is not derived from the variant name at runtime, so renaming
65            /// a variant cannot silently rename a host's configuration key.
66            pub const fn name(self) -> &'static str {
67                match self {
68                    $( StringKey::$variant => $name, )*
69                }
70            }
71
72            /// The English compiled into the binary.
73            pub const fn english(self) -> &'static str {
74                match self {
75                    $( StringKey::$variant => $default, )*
76                }
77            }
78
79            /// Looks a key up by its stable name.
80            pub fn from_name(name: &str) -> Option<Self> {
81                StringKey::ALL.iter().copied().find(|key| key.name() == name)
82            }
83        }
84    };
85}
86
87string_keys! {
88    // Shared vocabulary. One word used by more than one component is one key,
89    // so a host that renames it renames it everywhere it appears.
90    Copy => "common.copy", "Copy";
91    Dismiss => "common.dismiss", "Dismiss";
92    TryAgain => "common.try-again", "Try again";
93    Loading => "common.loading", "Loading";
94    MoreActions => "common.more-actions", "More actions";
95    Expand => "common.expand", "Expand";
96    Collapse => "common.collapse", "Collapse";
97
98    // Sensitive text controls.
99    PasswordReveal => "password.reveal", "Reveal password";
100    PasswordConceal => "password.conceal", "Conceal password";
101
102    // Calendar.
103    CalendarNoMonth => "calendar.no-month", "No month to show";
104    CalendarPreviousMonth => "calendar.previous-month", "Previous month";
105    CalendarNextMonth => "calendar.next-month", "Next month";
106    CalendarUnknownMonth => "calendar.unknown-month", "This calendar does not know which month to show";
107    CalendarUnknownMonthDetail => "calendar.unknown-month-detail", "Nothing is selected, the host has not said what day it is, and no month was given.";
108
109    // Date field.
110    DateInputOpen => "date-input.open", "Open the calendar";
111    DateInputPlaceholder => "date-input.placeholder", "Date";
112
113    // Range picker.
114    RangeUnset => "range.unset", "No range chosen yet.";
115    RangeIncomplete => "range.incomplete", "{0} to an end that has not been chosen yet.";
116    RangeComplete => "range.complete", "{0} to {1}.";
117    RangeInverted => "range.inverted", "The end, {0}, comes before the start, {1}.";
118    RangeUncheckable => "range.uncheckable", "The host cannot list the days in this range, so none of them were checked.";
119    RangeBlockedDay => "range.blocked-day", "{0}: {1}";
120
121    // Time field.
122    TimeHour => "time.hour", "Hour";
123    TimeMinute => "time.minute", "Minute";
124    TimeSecond => "time.second", "Second";
125    TimeMeridiem => "time.meridiem", "Half of the day";
126
127    // Dock, split, scroll, toolbar.
128    DockCollapseRegion => "dock.collapse-region", "Collapse region";
129    SplitResizeHandle => "split.resize-handle", "Resize panes";
130    StatusStale => "status.stale", "stale";
131    ScrollbarVertical => "scrollbar.vertical", "Vertical";
132    ScrollbarHorizontal => "scrollbar.horizontal", "Horizontal";
133
134    // Breadcrumb.
135    BreadcrumbHiddenOne => "breadcrumb.hidden-one", "1 hidden level";
136    BreadcrumbHiddenMany => "breadcrumb.hidden-many", "{0} hidden levels";
137
138    // In-page anchors.
139    AnchorMoreSections => "anchor.more-sections", "More sections";
140
141    // Pagination.
142    PaginationFirst => "pagination.first", "First page";
143    PaginationPrevious => "pagination.previous", "Previous page";
144    PaginationNext => "pagination.next", "Next page";
145    PaginationLast => "pagination.last", "Last page";
146    PaginationMorePages => "pagination.more-pages", "{0} more pages";
147    PaginationPageOfTotal => "pagination.page-of-total", "Page {0} of {1}";
148    PaginationPage => "pagination.page", "Page {0}";
149
150    // Wizard.
151    WizardBack => "wizard.back", "Back";
152    WizardNext => "wizard.next", "Next";
153    WizardFinish => "wizard.finish", "Finish";
154    WizardReturnsTo => "wizard.returns-to", "Returns to";
155
156    // Image viewer.
157    ImageViewerNotSupplied => "image-viewer.not-supplied", "Not supplied — {0}";
158    ImageViewerPrevious => "image-viewer.previous", "Previous image";
159    ImageViewerNext => "image-viewer.next", "Next image";
160    ImageViewerContain => "image-viewer.contain", "Contain";
161    ImageViewerCover => "image-viewer.cover", "Cover";
162    ImageViewerSizeUnknown => "image-viewer.size-unknown", "Size unknown";
163    ImageViewerEmpty => "image-viewer.empty", "No images";
164
165    // Markdown.
166    MarkdownImageAlt => "markdown.image-alt", "Image";
167    MarkdownImageNotFetched => "markdown.image-not-fetched", "Not fetched — {0}";
168    MarkdownPlainText => "markdown.plain-text", "plain text";
169    MarkdownTask => "markdown.task", "Task";
170    MarkdownUnrenderedHtml => "markdown.unrendered-html", "unrendered html";
171    MarkdownShowMoreOne => "markdown.show-more-one", "Show 1 more line";
172    MarkdownShowMoreMany => "markdown.show-more-many", "Show {0} more lines";
173
174    // Conversation.
175    MessageSending => "message.sending", "Sending";
176    MessageSent => "message.sent", "Sent";
177    MessageDelivered => "message.delivered", "Delivered";
178    MessageRead => "message.read", "Read";
179    MessageStreaming => "message.streaming", "Streaming";
180    MessageMoreOne => "message.more-one", "1 more message";
181    MessageMoreMany => "message.more-many", "{0} more messages";
182    MessageNewOne => "message.new-one", "1 new message";
183    MessageNewMany => "message.new-many", "{0} new messages";
184    MessageShowMoreOne => "message.show-more-one", "1 more line";
185    MessageShowMoreMany => "message.show-more-many", "{0} more lines";
186    TimeUnknown => "time.unknown", "Time unknown";
187
188    // Transport bar.
189    TransportBuffered => "transport.buffered", "Buffered";
190    TransportTimeUnknown => "transport.time-unknown", "Time unknown";
191    TransportDurationUnknown => "transport.duration-unknown", "Duration unknown";
192    TransportPosition => "transport.position", "Playback position";
193    TransportPlay => "transport.play", "Play";
194    TransportPause => "transport.pause", "Pause";
195    TransportPlaying => "transport.playing", "Playing";
196    TransportPaused => "transport.paused", "Paused";
197    TransportBuffering => "transport.buffering", "Waiting for data";
198    TransportMute => "transport.mute", "Mute";
199    TransportUnmute => "transport.unmute", "Unmute";
200    TransportVolume => "transport.volume", "Volume";
201    TransportPreviousTrack => "transport.previous-track", "Previous track";
202    TransportNextTrack => "transport.next-track", "Next track";
203
204    // The media players, over a transport this library does not implement.
205    MediaFixture => "media.fixture", "Fixture";
206    MediaNoTransport => "media.no-transport", "No player";
207    MediaNoTransportDetail => "media.no-transport-detail", "No player is connected to this surface, so there is nothing to start.";
208    MediaNoBackend => "media.no-backend", "No playback backend";
209    MediaFailed => "media.failed", "This could not be played";
210    MediaEmpty => "media.empty", "Nothing loaded";
211    MediaWaveform => "media.waveform", "Waveform";
212    VideoNoFrames => "video.no-frames", "No picture";
213    VideoNoFramesDetail => "video.no-frames-detail", "The transport holds this video and no frames have been supplied for it.";
214
215    // The bounded model viewer.
216    ModelEmpty => "model.empty", "No model";
217    ModelEmptyDetail => "model.empty-detail", "Nothing has been handed to this viewer.";
218    ModelRefused => "model.refused", "This model was refused";
219    ModelTooLarge => "model.too-large", "Too many {0}: it asks for {1}, and the limit is {2}.";
220    ModelRejected => "model.rejected", "It is outside the subset this reader accepts ({0}).";
221    ModelFlat => "model.flat", "Flat";
222    ModelWireframe => "model.wireframe", "Wireframe";
223    ModelReset => "model.reset", "Reset the view";
224    ModelCount => "model.count", "{0} {1}";
225    ModelMeshes => "model.meshes", "Meshes";
226    ModelVertices => "model.vertices", "Vertices";
227    ModelTriangles => "model.triangles", "Triangles";
228
229    // Combobox and select.
230    SelectPlaceholder => "select.placeholder", "Select";
231    ComboboxNoMatch => "combobox.no-match", "Nothing here answers “{0}”";
232    ComboboxCreateHint => "combobox.create-hint", "Press enter to add it as a new value.";
233    ComboboxClosedHint => "combobox.closed-hint", "This field only accepts one of the options offered.";
234
235    // Cascader.
236    CascaderPlaceholder => "cascader.placeholder", "Select";
237    CascaderUnstarted => "cascader.unstarted", "Nothing has been asked for yet";
238    CascaderEmpty => "cascader.empty", "No options";
239    CascaderUnavailable => "cascader.unavailable", "Options unavailable";
240    CascaderError => "cascader.error", "Could not load options";
241
242    // Drop zone.
243    DropzoneRefusal => "dropzone.refusal", "This zone does not take that.";
244
245    // Filter bar.
246    FilterBarLabel => "filter-bar.label", "Filters";
247    FilterBarAdd => "filter-bar.add", "Add filter";
248    FilterBarClear => "filter-bar.clear", "Clear all";
249    FilterBarCounting => "filter-bar.counting", "Counting…";
250    FilterBarResultsNoun => "filter-bar.results-noun", "results";
251
252    // Inline edit and keybinding recorder.
253    InlineEditPlaceholder => "inline-edit.placeholder", "Empty";
254    KeybindingUnbound => "keybinding.unbound", "Not bound";
255    KeybindingPrompt => "keybinding.prompt", "Press a shortcut";
256    KeymapAdd => "keymap.add", "Add binding";
257    KeymapRemove => "keymap.remove", "Remove";
258    KeymapReset => "keymap.reset", "Reset to defaults";
259    KeymapEffective => "keymap.effective", "Current bindings";
260    KeymapDefaults => "keymap.defaults", "Defaults";
261    KeymapResultCount => "keymap.result-count", "{0} commands";
262
263    // Number field.
264    NumberDecrease => "number.decrease", "Decrease";
265    NumberIncrease => "number.increase", "Increase";
266    NumberNotANumber => "number.not-a-number", "This is not a number.";
267    NumberBelowMinimum => "number.below-minimum", "The smallest accepted value is {0}.";
268    NumberAboveMaximum => "number.above-maximum", "The largest accepted value is {0}.";
269
270    // Settings row.
271    SettingsManagedBy => "settings.managed-by", "Managed by {0}";
272    SettingsInapplicable => "settings.inapplicable", "Not available here";
273
274    // Tag field and tag.
275    TagInputPlaceholder => "tag-input.placeholder", "Add";
276    TagInputDuplicate => "tag-input.duplicate", "“{0}” is already here";
277    TagInputFull => "tag-input.full", "This field holds at most {0}; “{1}” was not added";
278    TagInputUsed => "tag-input.used", "{0} of {1} used";
279    TagRemove => "tag.remove", "Remove {0}";
280
281    // Description list.
282    DescriptionUnknown => "description.unknown", "Unknown";
283    DescriptionNotApplicable => "description.not-applicable", "Not applicable";
284    DescriptionCopy => "description.copy", "Copy {0}";
285    DescriptionCharacters => "description.characters", "{0} characters";
286
287    // How a position in a run of things is worded. It is one key, because a
288    // reader who learns it on a progress bar should read the same shape on an
289    // image caption.
290    CountOfTotal => "common.count-of-total", "{0} of {1}";
291
292    // Command palette.
293    PalettePlaceholder => "palette.placeholder", "Type a command";
294    PaletteNoMatch => "palette.no-match", "No command matches “{0}”";
295    PaletteEmptyDetail => "palette.empty-detail", "Every command this application was given is listed here.";
296
297    // Keystroke names. The glyphs a Mac shows are not words and are not here;
298    // these are the spelled-out forms every other platform reads.
299    KbdSuper => "kbd.super", "Win";
300    KbdControl => "kbd.control", "Ctrl";
301    KbdAlt => "kbd.alt", "Alt";
302    KbdShift => "kbd.shift", "Shift";
303
304    // Data grid.
305    GridSelectedNoun => "grid.selected-noun", "selected";
306    GridSelectAllLoaded => "grid.select-all-loaded", "Select all loaded rows";
307    GridSelectAllTotal => "grid.select-all-total", "Select all {0}";
308    GridClearSelection => "grid.clear-selection", "Clear selection";
309    GridResizeColumn => "grid.resize-column", "Resize {0}";
310    GridLoadingRows => "grid.loading-rows", "Loading rows";
311    GridLoadFailed => "grid.load-failed", "Could not load rows";
312    GridEmpty => "grid.empty", "No rows";
313
314    // Diagnostics.
315    DiagnosticsUnstarted => "diagnostics.unstarted", "Diagnostics have not been requested";
316    DiagnosticsEmpty => "diagnostics.empty", "No diagnostics";
317    DiagnosticsNoMatch => "diagnostics.no-match", "No diagnostics match these filters";
318    DiagnosticsUnavailable => "diagnostics.unavailable", "Diagnostics unavailable";
319    DiagnosticsError => "diagnostics.error", "Could not load diagnostics";
320    DiagnosticsFilterField => "diagnostics.filter-field", "Severity";
321    DiagnosticsFilterOperator => "diagnostics.filter-operator", "is";
322    DiagnosticsSeverityError => "diagnostics.severity-error", "Error";
323    DiagnosticsSeverityWarning => "diagnostics.severity-warning", "Warning";
324    DiagnosticsSeverityInformation => "diagnostics.severity-information", "Information";
325    DiagnosticsSeverityHint => "diagnostics.severity-hint", "Hint";
326
327    // Drag and drop.
328    DragFileOne => "drag.file-one", "1 file";
329    DragFileMany => "drag.file-many", "{0} files";
330
331    // Copy button. The confirmation and the refusal are separate keys because
332    // they are separate claims: one says the clipboard took the text and the
333    // other says it did not, and a host wording them must not be able to
334    // collapse the two into the same sentence.
335    CopyDone => "copy.done", "Copied";
336    CopyFailed => "copy.failed", "Not copied";
337    CopyFailedDetail => "copy.failed-detail", "The clipboard did not take it.";
338
339    // Approval. The scope of an "always" is part of the wording on the
340    // control, so there is no key here for an unscoped one to be worded with.
341    ApprovalDecline => "approval.decline", "Decline";
342    ApprovalApproveOnce => "approval.approve-once", "Approve once";
343    ApprovalAlwaysSession => "approval.always-session", "Always for this session";
344    ApprovalAlwaysTool => "approval.always-tool", "Always for {0}";
345    ApprovalAlwaysPath => "approval.always-path", "Always in {0}";
346    ApprovalAlwaysHost => "approval.always-host", "Always on {0}";
347    ApprovalPending => "approval.pending", "Waiting for your answer";
348    ApprovalDeclined => "approval.declined", "Declined";
349    ApprovalApproved => "approval.approved", "Approved: {0}";
350    ApprovalOnceScope => "approval.once-scope", "this time only";
351    ApprovalExpired => "approval.expired", "This request expired before it was answered";
352    ApprovalSuperseded => "approval.superseded", "Replaced by {0}";
353
354    // Permission matrix.
355    PermissionAllowed => "permission.allowed", "Allowed";
356    PermissionDenied => "permission.denied", "Denied";
357    PermissionAsk => "permission.ask", "Ask every time";
358    PermissionNotApplicable => "permission.not-applicable", "Does not apply";
359    PermissionSubjectHeading => "permission.subject-heading", "Subject";
360    PermissionInherited => "permission.inherited", "Inherited from {0}";
361    PermissionSetHere => "permission.set-here", "Set here";
362    PermissionCellName => "permission.cell-name", "{0}: {1}";
363
364    // Cost and context. An estimate carries its label inside the value, so a
365    // reading cannot be worded without saying which of the two it is.
366    CostMeasured => "cost.measured", "{0}";
367    CostEstimated => "cost.estimated", "{0} (estimated)";
368    CostEstimateMark => "cost.estimate-mark", "Estimate";
369    CostUnavailable => "cost.unavailable", "Unavailable";
370    CostLastVerified => "cost.last-verified", "Last verified {0}";
371    ContextUnknownLimit => "context.unknown-limit", "Limit unknown";
372    // Structured value view. `null`, `true`, `{}` and `[]` are JSON syntax
373    // rather than words, so they are not here: translating them would produce
374    // a document nobody could paste back.
375    JsonWithheld => "json.withheld", "withheld";
376    JsonRootValue => "json.root-value", "Value";
377    JsonShapeEntries => "json.shape-entries", "{0} entries";
378    JsonShapeItems => "json.shape-items", "{0} items";
379    JsonShapeValue => "json.shape-value", "a value";
380
381    // Schema-generated form.
382    SchemaUnrenderable => "schema.unrenderable", "This field cannot be shown here, so it has to be filled in some other way.";
383    SchemaUnrenderableRequired => "schema.unrenderable-required", "This field is required and cannot be shown here, so this form cannot complete the call.";
384    SchemaNoChoices => "schema.no-choices", "No choices were offered, so there is nothing to pick.";
385    SchemaRequiredMissing => "schema.required-missing", "This field is required.";
386    SchemaUnrenderableOne => "schema.unrenderable-one", "1 field cannot be shown here.";
387    SchemaUnrenderableMany => "schema.unrenderable-many", "{0} fields cannot be shown here.";
388
389    // Connections, and what each one offers.
390    ServerConnected => "server.connected", "Connected";
391    ServerConnecting => "server.connecting", "Connecting";
392    ServerDisconnected => "server.disconnected", "Disconnected";
393    ServerFailed => "server.failed", "Failed";
394    ServerDisabled => "server.disabled", "Turned off";
395    ServerTools => "server.tools", "Tools";
396    ServerSkills => "server.skills", "Skills";
397    ServerResources => "server.resources", "Resources";
398    ServerOfferingsUnasked => "server.offerings-unasked", "Nothing has been asked for yet";
399    ServerOfferingsUnaskedDetail => "server.offerings-unasked-detail", "This connection has not been asked what it offers.";
400    ServerOfferingsAsking => "server.offerings-asking", "Asking what this connection offers";
401    ServerOfferingsNone => "server.offerings-none", "This connection offers nothing";
402    ServerOfferingsNoneDetail => "server.offerings-none-detail", "It answered, and the answer was empty.";
403    ServerOfferingsUnavailable => "server.offerings-unavailable", "What this connection offers is unknown";
404    ServerEmpty => "server.empty", "No connections";
405    ServerEmptyDetail => "server.empty-detail", "Nothing has been connected yet.";
406
407    // A run: one tool call, the steps it belongs to, and the reasoning beside
408    // them. The state words are shared between the card and the list, because
409    // a call that is running and a step that is running are one word to a
410    // reader.
411    AgentArguments => "agent.arguments", "Arguments";
412    AgentResult => "agent.result", "Result";
413    AgentPendingApproval => "agent.pending-approval", "Waiting for approval";
414    AgentRunning => "agent.running", "Running";
415    AgentSucceeded => "agent.succeeded", "Succeeded";
416    AgentFailed => "agent.failed", "Failed";
417    AgentDeclined => "agent.declined", "Declined";
418    AgentPending => "agent.pending", "Pending";
419    AgentDone => "agent.done", "Done";
420    AgentSkipped => "agent.skipped", "Skipped";
421    AgentNoOutput => "agent.no-output", "This tool returned nothing";
422    AgentElapsedUnknown => "agent.elapsed-unknown", "Elapsed time unknown";
423    AgentTruncated => "agent.truncated", "{0} of {1} lines shown";
424    AgentLinesOne => "agent.lines-one", "1 line";
425    AgentLinesMany => "agent.lines-many", "{0} lines";
426    AgentStepsDoneOne => "agent.steps-done-one", "1 step done";
427    AgentStepsDoneMany => "agent.steps-done-many", "{0} steps done";
428    AgentReasoning => "agent.reasoning", "Reasoning";
429    AgentReasoningWithheld => "agent.reasoning-withheld", "Withheld";
430    AgentReasoningThinking => "agent.reasoning-thinking", "Thinking";
431    AgentReasoningAbsent => "agent.reasoning-absent", "No reasoning was returned";
432
433    // Document tabs. A clean tab carries no wording at all, which is why
434    // there is no key here for one: silence is the whole message.
435    TabDirty => "tab.dirty", "Unsaved changes";
436    TabSaving => "tab.saving", "Saving";
437    TabSaveFailed => "tab.save-failed", "Could not save";
438    TabClose => "tab.close", "Close {0}";
439    TabMoreTabs => "tab.more-tabs", "More tabs";
440
441    // Search, and find and replace.
442    SearchPlaceholder => "search.placeholder", "Find";
443    SearchNoHits => "search.no-hits", "No results";
444    SearchCounting => "search.counting", "Counting…";
445    SearchNotSearched => "search.not-searched", "Nothing searched yet";
446    SearchTooMany => "search.too-many", "More than {0}";
447    SearchHitOne => "search.hit-one", "1 result";
448    SearchHitMany => "search.hit-many", "{0} results";
449    SearchNext => "search.next", "Next result";
450    SearchPrevious => "search.previous", "Previous result";
451    SearchCaseSensitive => "search.case-sensitive", "Match case";
452    SearchWholeWord => "search.whole-word", "Whole word";
453    ReplacePlaceholder => "replace.placeholder", "Replace with";
454    ReplaceOne => "replace.one", "Replace";
455    ReplaceAllCounted => "replace.all-counted", "Replace all {0}";
456    ReplaceAllUncounted => "replace.all-uncounted", "Replace all";
457    ReplaceAllUncountable => "replace.all-uncountable", "Nobody has counted the results yet, so this cannot say how many it would change.";
458
459    // Notification centre.
460    NotificationsTitle => "notifications.title", "Notifications";
461    NotificationsEmpty => "notifications.empty", "Nothing to report";
462    NotificationsEmptyDetail => "notifications.empty-detail", "Notifications that have come and gone are kept here.";
463    NotificationsClearAll => "notifications.clear-all", "Clear all";
464    NotificationsMarkAllRead => "notifications.mark-all-read", "Mark all as read";
465    NotificationsUnread => "notifications.unread", "Unread";
466    NotificationsUnreadCount => "notifications.unread-count", "{0} unread";
467    NotificationsAtLeast => "notifications.at-least", "{0}+";
468
469    // A panel whose contents the host could not produce.
470    FailureTitle => "failure.title", "This panel could not be shown";
471    FailureAttempts => "failure.attempts", "Tried {0} times";
472    FailureRetrying => "failure.retrying", "Trying again";
473
474    // Read-only code.
475    CodeLineNumbers => "code.line-numbers", "Line numbers";
476    CodeLineAdded => "code.line-added", "Added";
477    CodeLineRemoved => "code.line-removed", "Removed";
478    CodeLineChanged => "code.line-changed", "Changed";
479    CodeLineHighlighted => "code.line-highlighted", "Highlighted";
480    CodeLineError => "code.line-error", "Error";
481    CodeEmpty => "code.empty", "Nothing to show";
482
483    // Developer and data readings. Log metadata and metric values are caller
484    // strings; only controls and state names belong to this catalogue.
485    LogFollow => "log.follow", "Follow output";
486    LogPause => "log.pause", "Pause output";
487    LogFollowing => "log.following", "Following newest";
488    LogPaused => "log.paused", "Follow paused";
489    LogEmpty => "log.empty", "No log entries";
490    LogUnavailable => "log.unavailable", "Log unavailable";
491    LogError => "log.error", "Could not load log";
492    DiffFile => "diff.file", "File";
493    DiffHunk => "diff.hunk", "Hunk";
494    DiffContextLine => "diff.context-line", "Context line";
495    DiffChangedLine => "diff.changed-line", "Changed line";
496    DiffEmpty => "diff.empty", "No differences";
497    SparklineEmpty => "sparkline.empty", "No readings";
498    SparklineUnavailable => "sparkline.unavailable", "Reading unavailable";
499    SparklineError => "sparkline.error", "Could not load reading";
500    SparklineCurrent => "sparkline.current", "Current: {0}";
501    SparklineMinimum => "sparkline.minimum", "Minimum: {0}";
502    SparklineMaximum => "sparkline.maximum", "Maximum: {0}";
503    SparklineRange => "sparkline.range", "Minimum {0}; maximum {1}";
504
505    // Uploads.
506    UploadQueued => "upload.queued", "Queued";
507    UploadUploading => "upload.uploading", "Uploading";
508    UploadDone => "upload.done", "Uploaded";
509    UploadFailed => "upload.failed", "Failed";
510    UploadCancelled => "upload.cancelled", "Cancelled";
511    UploadRefused => "upload.refused", "Not accepted";
512    UploadCancel => "upload.cancel", "Cancel {0}";
513    UploadRemove => "upload.remove", "Remove {0}";
514    UploadOverall => "upload.overall", "Uploading";
515    UploadEmpty => "upload.empty", "No files yet";
516
517    // The web view shell. The four things that are not a page each say a
518    // different thing, so each of them is its own key rather than one
519    // "cannot show" a host would have to disambiguate by guessing.
520    BrowserPanel => "browser.panel", "Browser";
521    BrowserBack => "browser.back", "Back";
522    BrowserForward => "browser.forward", "Forward";
523    BrowserReload => "browser.reload", "Reload";
524    BrowserNoAddress => "browser.no-address", "No address";
525    BrowserEmpty => "browser.empty", "No page content";
526    BrowserEmptyDetail => "browser.empty-detail", "The page loaded without content.";
527    BrowserUnavailable => "browser.unavailable", "Web content unavailable";
528    BrowserNoEngineDetail => "browser.no-engine-detail", "This build cannot display web content.";
529    BrowserError => "browser.error", "Could not load";
530    BrowserNoViewport => "browser.no-viewport", "No page surface";
531    BrowserNoViewportDetail => "browser.no-viewport-detail",
532        "The host reported a ready page but supplied no viewport.";
533
534    // Offerings aggregated across caller-owned server sources.
535    OfferingCatalogEmpty => "offering-catalog.empty", "No offerings";
536    OfferingCatalogNoMatch => "offering-catalog.no-match", "No matching offerings";
537    OfferingSourceLoading => "offering-source.loading", "{0}: Loading offerings";
538    OfferingSourceEmpty => "offering-source.empty", "{0}: No offerings";
539    OfferingSourceUnavailable => "offering-source.unavailable", "{0}: Offerings unavailable";
540    OfferingSourceError => "offering-source.error", "{0}: Could not load offerings";
541    OfferingSourceStale => "offering-source.stale", "{0}: Showing last verified offerings";
542}
543
544/// The catalogue a host installs, and the one components read.
545///
546/// It holds only the entries a host replaced. An absent entry is not a gap to
547/// be filled at runtime; it means the English default stands, which is why a
548/// partial catalogue is a legitimate thing to install rather than a mistake.
549#[derive(Debug, Clone, Default)]
550pub struct Strings {
551    overrides: BTreeMap<StringKey, SharedString>,
552}
553
554impl Global for Strings {}
555
556impl Strings {
557    /// An empty catalogue: every key answers with its English.
558    pub fn new() -> Self {
559        Self::default()
560    }
561
562    /// Replaces one entry.
563    pub fn set(&mut self, key: StringKey, text: impl Into<SharedString>) -> &mut Self {
564        self.overrides.insert(key, text.into());
565        self
566    }
567
568    /// Restores the English for one entry.
569    pub fn clear(&mut self, key: StringKey) -> &mut Self {
570        self.overrides.remove(&key);
571        self
572    }
573
574    /// Restores the English for every entry.
575    pub fn clear_all(&mut self) -> &mut Self {
576        self.overrides.clear();
577        self
578    }
579
580    /// Replaces many entries, leaving the rest alone.
581    pub fn extend(
582        &mut self,
583        entries: impl IntoIterator<Item = (StringKey, SharedString)>,
584    ) -> &mut Self {
585        self.overrides.extend(entries);
586        self
587    }
588
589    /// Whether this key was replaced. A component never asks; a test does.
590    pub fn is_overridden(&self, key: StringKey) -> bool {
591        self.overrides.contains_key(&key)
592    }
593
594    /// The text behind a key, which is always something a reader can read.
595    pub fn text(&self, key: StringKey) -> SharedString {
596        match self.overrides.get(&key) {
597            Some(text) => text.clone(),
598            None => SharedString::new_static(key.english()),
599        }
600    }
601
602    /// The text behind a key with `{0}`, `{1}`, … replaced in place.
603    ///
604    /// A placeholder with no argument is left standing rather than removed, so
605    /// a wrong translation reads as an obvious mistake instead of a sentence
606    /// that quietly lost a fact.
607    pub fn format(&self, key: StringKey, args: &[&str]) -> SharedString {
608        SharedString::from(interpolate(self.text(key).as_ref(), args))
609    }
610}
611
612fn interpolate(template: &str, args: &[&str]) -> String {
613    let mut out = String::with_capacity(template.len());
614    let mut rest = template;
615    while let Some(open) = rest.find('{') {
616        let (before, tail) = rest.split_at(open);
617        out.push_str(before);
618        let Some(close) = tail.find('}') else {
619            out.push_str(tail);
620            return out;
621        };
622        let slot = &tail[1..close];
623        match slot.parse::<usize>().ok().and_then(|index| args.get(index)) {
624            Some(value) => out.push_str(value),
625            None => out.push_str(&tail[..=close]),
626        }
627        rest = &tail[close + 1..];
628    }
629    out.push_str(rest);
630    out
631}
632
633/// Reads the installed catalogue from any context that dereferences to
634/// [`App`], mirroring [`ActiveTheme`](gpui_kit_theme::ActiveTheme).
635pub trait ActiveStrings {
636    fn strings(&self) -> &Strings;
637}
638
639impl ActiveStrings for App {
640    /// Falls back to the English catalogue when no host installed one, because
641    /// a component that panicked or rendered blank for want of a global would
642    /// be a worse library than one with English compiled in.
643    fn strings(&self) -> &Strings {
644        static ENGLISH: OnceLock<Strings> = OnceLock::new();
645        self.try_global::<Strings>()
646            .unwrap_or_else(|| ENGLISH.get_or_init(Strings::new))
647    }
648}
649
650/// Installs the catalogue global. Idempotent, and never discards a catalogue a
651/// host already installed.
652pub fn install(cx: &mut App) {
653    if !cx.has_global::<Strings>() {
654        cx.set_global(Strings::new());
655    }
656}
657
658/// Replaces entries and repaints every window, the way
659/// [`activate_theme`](gpui_kit_theme::activate_theme) does.
660pub fn set_strings(entries: impl IntoIterator<Item = (StringKey, SharedString)>, cx: &mut App) {
661    install(cx);
662    cx.update_global::<Strings, ()>(|strings, _| {
663        strings.extend(entries);
664    });
665    cx.refresh_windows();
666}
667
668/// Restores the English for every entry and repaints every window.
669pub fn reset_strings(cx: &mut App) {
670    install(cx);
671    cx.update_global::<Strings, ()>(|strings, _| {
672        strings.clear_all();
673    });
674    cx.refresh_windows();
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[test]
682    fn every_key_has_a_unique_name_and_english() {
683        let mut names: Vec<&str> = StringKey::ALL.iter().map(|key| key.name()).collect();
684        names.sort_unstable();
685        let before = names.len();
686        names.dedup();
687        assert_eq!(names.len(), before, "two keys share a name");
688        for key in StringKey::ALL {
689            assert!(!key.english().is_empty(), "{} has no English", key.name());
690            assert_eq!(StringKey::from_name(key.name()), Some(*key));
691        }
692    }
693
694    #[test]
695    fn an_empty_catalogue_answers_in_english() {
696        let strings = Strings::new();
697        assert_eq!(strings.text(StringKey::Copy), "Copy");
698        assert_eq!(strings.text(StringKey::TryAgain), "Try again");
699    }
700
701    #[test]
702    fn an_override_replaces_only_what_it_names() {
703        let mut strings = Strings::new();
704        strings.set(StringKey::Copy, "Kopieren");
705        assert_eq!(strings.text(StringKey::Copy), "Kopieren");
706        assert_eq!(strings.text(StringKey::TryAgain), "Try again");
707        strings.clear(StringKey::Copy);
708        assert_eq!(strings.text(StringKey::Copy), "Copy");
709    }
710
711    #[test]
712    fn a_template_takes_its_arguments_in_any_order() {
713        let mut strings = Strings::new();
714        assert_eq!(
715            strings.format(StringKey::RangeComplete, &["Monday", "Friday"]),
716            "Monday to Friday."
717        );
718        strings.set(StringKey::RangeComplete, "{1} back to {0}.");
719        assert_eq!(
720            strings.format(StringKey::RangeComplete, &["Monday", "Friday"]),
721            "Friday back to Monday."
722        );
723    }
724
725    #[test]
726    fn a_placeholder_with_no_argument_stays_visible() {
727        let strings = Strings::new();
728        assert_eq!(
729            strings.format(StringKey::RangeComplete, &["Monday"]),
730            "Monday to {1}."
731        );
732    }
733
734    #[test]
735    fn every_placeholder_in_the_english_is_numbered_from_zero() {
736        for key in StringKey::ALL {
737            let english = key.english();
738            let mut expected = 0usize;
739            let mut rest = english;
740            while let Some(open) = rest.find('{') {
741                let tail = &rest[open..];
742                let close = tail.find('}').unwrap_or_else(|| {
743                    panic!("{} has an unclosed placeholder", key.name());
744                });
745                let slot = &tail[1..close];
746                assert_eq!(
747                    slot.parse::<usize>().ok(),
748                    Some(expected),
749                    "{} numbers its placeholders out of order",
750                    key.name()
751                );
752                expected += 1;
753                rest = &tail[close + 1..];
754            }
755        }
756    }
757}