gpui-box-kit 0.1.0

GPUI Box Kit design-system components and interaction primitives
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
//! The words this library puts on screen, and the host's right to replace
//! them.
//!
//! Components never hold English. They hold a [`StringKey`], and ask the
//! application context for the text behind it at render time, exactly the way
//! they ask for a colour:
//!
//! ```no_run
//! # use gpui_kit::strings::{ActiveStrings, StringKey};
//! # fn example(cx: &gpui::App) -> gpui::SharedString {
//! cx.strings().text(StringKey::Copy)
//! # }
//! ```
//!
//! Every key carries an English default compiled into the binary, so a host
//! that supplies nothing still gets a working, readable interface. A host that
//! supplies some keys gets its own words for those and English for the rest;
//! there is no state in which a label renders empty.
//!
//! Strings with a value in them are templates over positional placeholders —
//! `{0}`, `{1}` — rather than Rust format strings, because a translation is
//! allowed to put the value somewhere else in the sentence:
//!
//! ```no_run
//! # use gpui_kit::strings::{ActiveStrings, StringKey};
//! # fn example(cx: &gpui::App, query: &str) -> gpui::SharedString {
//! cx.strings().format(StringKey::PaletteNoMatch, &[query])
//! # }
//! ```
//!
//! # What is not here
//!
//! Numbers, dates, and quantities are not translated. A count is still
//! rendered with Rust's own digits and a plural is still chosen by an
//! `if count == 1` at the call site, which is correct for English and for
//! nothing else. `docs/coverage.md` records that as a named gap.

use std::collections::BTreeMap;
use std::sync::OnceLock;

use gpui::{App, BorrowAppContext, Global, SharedString};

/// Declares every key once: the variant, the stable name a host and a test
/// use to address it, and the English a host gets for free.
macro_rules! string_keys {
    ($( $variant:ident => $name:literal, $default:literal ; )*) => {
        /// Every piece of text this library can put on a screen.
        ///
        /// The set is closed and exhaustive: a component that needs a new word
        /// adds a variant here, which is what makes a missing translation a
        /// compile-time question rather than a runtime blank.
        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
        #[non_exhaustive]
        pub enum StringKey {
            $( #[doc = $default] $variant, )*
        }

        impl StringKey {
            /// Every key, in declaration order.
            pub const ALL: &'static [StringKey] = &[ $( StringKey::$variant, )* ];

            /// The stable name a host configuration or a test uses.
            ///
            /// It is not derived from the variant name at runtime, so renaming
            /// a variant cannot silently rename a host's configuration key.
            pub const fn name(self) -> &'static str {
                match self {
                    $( StringKey::$variant => $name, )*
                }
            }

            /// The English compiled into the binary.
            pub const fn english(self) -> &'static str {
                match self {
                    $( StringKey::$variant => $default, )*
                }
            }

            /// Looks a key up by its stable name.
            pub fn from_name(name: &str) -> Option<Self> {
                StringKey::ALL.iter().copied().find(|key| key.name() == name)
            }
        }
    };
}

string_keys! {
    // Shared vocabulary. One word used by more than one component is one key,
    // so a host that renames it renames it everywhere it appears.
    Copy => "common.copy", "Copy";
    Dismiss => "common.dismiss", "Dismiss";
    TryAgain => "common.try-again", "Try again";
    Loading => "common.loading", "Loading";
    MoreActions => "common.more-actions", "More actions";
    Expand => "common.expand", "Expand";
    Collapse => "common.collapse", "Collapse";

    // Sensitive text controls.
    PasswordReveal => "password.reveal", "Reveal password";
    PasswordConceal => "password.conceal", "Conceal password";

    // Calendar.
    CalendarNoMonth => "calendar.no-month", "No month to show";
    CalendarPreviousMonth => "calendar.previous-month", "Previous month";
    CalendarNextMonth => "calendar.next-month", "Next month";
    CalendarUnknownMonth => "calendar.unknown-month", "This calendar does not know which month to show";
    CalendarUnknownMonthDetail => "calendar.unknown-month-detail", "Nothing is selected, the host has not said what day it is, and no month was given.";

    // Date field.
    DateInputOpen => "date-input.open", "Open the calendar";
    DateInputPlaceholder => "date-input.placeholder", "Date";

    // Range picker.
    RangeUnset => "range.unset", "No range chosen yet.";
    RangeIncomplete => "range.incomplete", "{0} to an end that has not been chosen yet.";
    RangeComplete => "range.complete", "{0} to {1}.";
    RangeInverted => "range.inverted", "The end, {0}, comes before the start, {1}.";
    RangeUncheckable => "range.uncheckable", "The host cannot list the days in this range, so none of them were checked.";
    RangeBlockedDay => "range.blocked-day", "{0}: {1}";

    // Time field.
    TimeHour => "time.hour", "Hour";
    TimeMinute => "time.minute", "Minute";
    TimeSecond => "time.second", "Second";
    TimeMeridiem => "time.meridiem", "Half of the day";

    // Dock, split, scroll, toolbar.
    DockCollapseRegion => "dock.collapse-region", "Collapse region";
    SplitResizeHandle => "split.resize-handle", "Resize panes";
    StatusStale => "status.stale", "stale";
    ScrollbarVertical => "scrollbar.vertical", "Vertical";
    ScrollbarHorizontal => "scrollbar.horizontal", "Horizontal";

    // Breadcrumb.
    BreadcrumbHiddenOne => "breadcrumb.hidden-one", "1 hidden level";
    BreadcrumbHiddenMany => "breadcrumb.hidden-many", "{0} hidden levels";

    // In-page anchors.
    AnchorMoreSections => "anchor.more-sections", "More sections";

    // Pagination.
    PaginationFirst => "pagination.first", "First page";
    PaginationPrevious => "pagination.previous", "Previous page";
    PaginationNext => "pagination.next", "Next page";
    PaginationLast => "pagination.last", "Last page";
    PaginationMorePages => "pagination.more-pages", "{0} more pages";
    PaginationPageOfTotal => "pagination.page-of-total", "Page {0} of {1}";
    PaginationPage => "pagination.page", "Page {0}";

    // Wizard.
    WizardBack => "wizard.back", "Back";
    WizardNext => "wizard.next", "Next";
    WizardFinish => "wizard.finish", "Finish";
    WizardReturnsTo => "wizard.returns-to", "Returns to";

    // Image viewer.
    ImageViewerNotSupplied => "image-viewer.not-supplied", "Not supplied — {0}";
    ImageViewerPrevious => "image-viewer.previous", "Previous image";
    ImageViewerNext => "image-viewer.next", "Next image";
    ImageViewerContain => "image-viewer.contain", "Contain";
    ImageViewerCover => "image-viewer.cover", "Cover";
    ImageViewerSizeUnknown => "image-viewer.size-unknown", "Size unknown";
    ImageViewerEmpty => "image-viewer.empty", "No images";

    // Markdown.
    MarkdownImageAlt => "markdown.image-alt", "Image";
    MarkdownImageNotFetched => "markdown.image-not-fetched", "Not fetched — {0}";
    MarkdownPlainText => "markdown.plain-text", "plain text";
    MarkdownTask => "markdown.task", "Task";
    MarkdownUnrenderedHtml => "markdown.unrendered-html", "unrendered html";
    MarkdownShowMoreOne => "markdown.show-more-one", "Show 1 more line";
    MarkdownShowMoreMany => "markdown.show-more-many", "Show {0} more lines";

    // Conversation.
    MessageSending => "message.sending", "Sending";
    MessageSent => "message.sent", "Sent";
    MessageDelivered => "message.delivered", "Delivered";
    MessageRead => "message.read", "Read";
    MessageStreaming => "message.streaming", "Streaming";
    MessageMoreOne => "message.more-one", "1 more message";
    MessageMoreMany => "message.more-many", "{0} more messages";
    MessageNewOne => "message.new-one", "1 new message";
    MessageNewMany => "message.new-many", "{0} new messages";
    MessageShowMoreOne => "message.show-more-one", "1 more line";
    MessageShowMoreMany => "message.show-more-many", "{0} more lines";
    TimeUnknown => "time.unknown", "Time unknown";

    // Transport bar.
    TransportBuffered => "transport.buffered", "Buffered";
    TransportTimeUnknown => "transport.time-unknown", "Time unknown";
    TransportDurationUnknown => "transport.duration-unknown", "Duration unknown";
    TransportPosition => "transport.position", "Playback position";
    TransportPlay => "transport.play", "Play";
    TransportPause => "transport.pause", "Pause";
    TransportPlaying => "transport.playing", "Playing";
    TransportPaused => "transport.paused", "Paused";
    TransportBuffering => "transport.buffering", "Waiting for data";
    TransportMute => "transport.mute", "Mute";
    TransportUnmute => "transport.unmute", "Unmute";
    TransportVolume => "transport.volume", "Volume";
    TransportPreviousTrack => "transport.previous-track", "Previous track";
    TransportNextTrack => "transport.next-track", "Next track";

    // The media players, over a transport this library does not implement.
    MediaFixture => "media.fixture", "Fixture";
    MediaNoTransport => "media.no-transport", "No player";
    MediaNoTransportDetail => "media.no-transport-detail", "No player is connected to this surface, so there is nothing to start.";
    MediaNoBackend => "media.no-backend", "No playback backend";
    MediaFailed => "media.failed", "This could not be played";
    MediaEmpty => "media.empty", "Nothing loaded";
    MediaWaveform => "media.waveform", "Waveform";
    VideoNoFrames => "video.no-frames", "No picture";
    VideoNoFramesDetail => "video.no-frames-detail", "The transport holds this video and no frames have been supplied for it.";

    // The bounded model viewer.
    ModelEmpty => "model.empty", "No model";
    ModelEmptyDetail => "model.empty-detail", "Nothing has been handed to this viewer.";
    ModelRefused => "model.refused", "This model was refused";
    ModelTooLarge => "model.too-large", "Too many {0}: it asks for {1}, and the limit is {2}.";
    ModelRejected => "model.rejected", "It is outside the subset this reader accepts ({0}).";
    ModelFlat => "model.flat", "Flat";
    ModelWireframe => "model.wireframe", "Wireframe";
    ModelReset => "model.reset", "Reset the view";
    ModelCount => "model.count", "{0} {1}";
    ModelMeshes => "model.meshes", "Meshes";
    ModelVertices => "model.vertices", "Vertices";
    ModelTriangles => "model.triangles", "Triangles";

    // Combobox and select.
    SelectPlaceholder => "select.placeholder", "Select";
    ComboboxNoMatch => "combobox.no-match", "Nothing here answers “{0}”";
    ComboboxCreateHint => "combobox.create-hint", "Press enter to add it as a new value.";
    ComboboxClosedHint => "combobox.closed-hint", "This field only accepts one of the options offered.";

    // Cascader.
    CascaderPlaceholder => "cascader.placeholder", "Select";
    CascaderUnstarted => "cascader.unstarted", "Nothing has been asked for yet";
    CascaderEmpty => "cascader.empty", "No options";
    CascaderUnavailable => "cascader.unavailable", "Options unavailable";
    CascaderError => "cascader.error", "Could not load options";

    // Drop zone.
    DropzoneRefusal => "dropzone.refusal", "This zone does not take that.";

    // Filter bar.
    FilterBarLabel => "filter-bar.label", "Filters";
    FilterBarAdd => "filter-bar.add", "Add filter";
    FilterBarClear => "filter-bar.clear", "Clear all";
    FilterBarCounting => "filter-bar.counting", "Counting…";
    FilterBarResultsNoun => "filter-bar.results-noun", "results";

    // Inline edit and keybinding recorder.
    InlineEditPlaceholder => "inline-edit.placeholder", "Empty";
    KeybindingUnbound => "keybinding.unbound", "Not bound";
    KeybindingPrompt => "keybinding.prompt", "Press a shortcut";
    KeymapAdd => "keymap.add", "Add binding";
    KeymapRemove => "keymap.remove", "Remove";
    KeymapReset => "keymap.reset", "Reset to defaults";
    KeymapEffective => "keymap.effective", "Current bindings";
    KeymapDefaults => "keymap.defaults", "Defaults";
    KeymapResultCount => "keymap.result-count", "{0} commands";

    // Number field.
    NumberDecrease => "number.decrease", "Decrease";
    NumberIncrease => "number.increase", "Increase";
    NumberNotANumber => "number.not-a-number", "This is not a number.";
    NumberBelowMinimum => "number.below-minimum", "The smallest accepted value is {0}.";
    NumberAboveMaximum => "number.above-maximum", "The largest accepted value is {0}.";

    // Settings row.
    SettingsManagedBy => "settings.managed-by", "Managed by {0}";
    SettingsInapplicable => "settings.inapplicable", "Not available here";

    // Tag field and tag.
    TagInputPlaceholder => "tag-input.placeholder", "Add";
    TagInputDuplicate => "tag-input.duplicate", "“{0}” is already here";
    TagInputFull => "tag-input.full", "This field holds at most {0}; “{1}” was not added";
    TagInputUsed => "tag-input.used", "{0} of {1} used";
    TagRemove => "tag.remove", "Remove {0}";

    // Description list.
    DescriptionUnknown => "description.unknown", "Unknown";
    DescriptionNotApplicable => "description.not-applicable", "Not applicable";
    DescriptionCopy => "description.copy", "Copy {0}";
    DescriptionCharacters => "description.characters", "{0} characters";

    // How a position in a run of things is worded. It is one key, because a
    // reader who learns it on a progress bar should read the same shape on an
    // image caption.
    CountOfTotal => "common.count-of-total", "{0} of {1}";

    // Command palette.
    PalettePlaceholder => "palette.placeholder", "Type a command";
    PaletteNoMatch => "palette.no-match", "No command matches “{0}”";
    PaletteEmptyDetail => "palette.empty-detail", "Every command this application was given is listed here.";

    // Keystroke names. The glyphs a Mac shows are not words and are not here;
    // these are the spelled-out forms every other platform reads.
    KbdSuper => "kbd.super", "Win";
    KbdControl => "kbd.control", "Ctrl";
    KbdAlt => "kbd.alt", "Alt";
    KbdShift => "kbd.shift", "Shift";

    // Data grid.
    GridSelectedNoun => "grid.selected-noun", "selected";
    GridSelectAllLoaded => "grid.select-all-loaded", "Select all loaded rows";
    GridSelectAllTotal => "grid.select-all-total", "Select all {0}";
    GridClearSelection => "grid.clear-selection", "Clear selection";
    GridResizeColumn => "grid.resize-column", "Resize {0}";
    GridLoadingRows => "grid.loading-rows", "Loading rows";
    GridLoadFailed => "grid.load-failed", "Could not load rows";
    GridEmpty => "grid.empty", "No rows";

    // Diagnostics.
    DiagnosticsUnstarted => "diagnostics.unstarted", "Diagnostics have not been requested";
    DiagnosticsEmpty => "diagnostics.empty", "No diagnostics";
    DiagnosticsNoMatch => "diagnostics.no-match", "No diagnostics match these filters";
    DiagnosticsUnavailable => "diagnostics.unavailable", "Diagnostics unavailable";
    DiagnosticsError => "diagnostics.error", "Could not load diagnostics";
    DiagnosticsFilterField => "diagnostics.filter-field", "Severity";
    DiagnosticsFilterOperator => "diagnostics.filter-operator", "is";
    DiagnosticsSeverityError => "diagnostics.severity-error", "Error";
    DiagnosticsSeverityWarning => "diagnostics.severity-warning", "Warning";
    DiagnosticsSeverityInformation => "diagnostics.severity-information", "Information";
    DiagnosticsSeverityHint => "diagnostics.severity-hint", "Hint";

    // Drag and drop.
    DragFileOne => "drag.file-one", "1 file";
    DragFileMany => "drag.file-many", "{0} files";

    // Copy button. The confirmation and the refusal are separate keys because
    // they are separate claims: one says the clipboard took the text and the
    // other says it did not, and a host wording them must not be able to
    // collapse the two into the same sentence.
    CopyDone => "copy.done", "Copied";
    CopyFailed => "copy.failed", "Not copied";
    CopyFailedDetail => "copy.failed-detail", "The clipboard did not take it.";

    // Approval. The scope of an "always" is part of the wording on the
    // control, so there is no key here for an unscoped one to be worded with.
    ApprovalDecline => "approval.decline", "Decline";
    ApprovalApproveOnce => "approval.approve-once", "Approve once";
    ApprovalAlwaysSession => "approval.always-session", "Always for this session";
    ApprovalAlwaysTool => "approval.always-tool", "Always for {0}";
    ApprovalAlwaysPath => "approval.always-path", "Always in {0}";
    ApprovalAlwaysHost => "approval.always-host", "Always on {0}";
    ApprovalPending => "approval.pending", "Waiting for your answer";
    ApprovalDeclined => "approval.declined", "Declined";
    ApprovalApproved => "approval.approved", "Approved: {0}";
    ApprovalOnceScope => "approval.once-scope", "this time only";
    ApprovalExpired => "approval.expired", "This request expired before it was answered";
    ApprovalSuperseded => "approval.superseded", "Replaced by {0}";

    // Permission matrix.
    PermissionAllowed => "permission.allowed", "Allowed";
    PermissionDenied => "permission.denied", "Denied";
    PermissionAsk => "permission.ask", "Ask every time";
    PermissionNotApplicable => "permission.not-applicable", "Does not apply";
    PermissionSubjectHeading => "permission.subject-heading", "Subject";
    PermissionInherited => "permission.inherited", "Inherited from {0}";
    PermissionSetHere => "permission.set-here", "Set here";
    PermissionCellName => "permission.cell-name", "{0}: {1}";

    // Cost and context. An estimate carries its label inside the value, so a
    // reading cannot be worded without saying which of the two it is.
    CostMeasured => "cost.measured", "{0}";
    CostEstimated => "cost.estimated", "{0} (estimated)";
    CostEstimateMark => "cost.estimate-mark", "Estimate";
    CostUnavailable => "cost.unavailable", "Unavailable";
    CostLastVerified => "cost.last-verified", "Last verified {0}";
    ContextUnknownLimit => "context.unknown-limit", "Limit unknown";
    // Structured value view. `null`, `true`, `{}` and `[]` are JSON syntax
    // rather than words, so they are not here: translating them would produce
    // a document nobody could paste back.
    JsonWithheld => "json.withheld", "withheld";
    JsonRootValue => "json.root-value", "Value";
    JsonShapeEntries => "json.shape-entries", "{0} entries";
    JsonShapeItems => "json.shape-items", "{0} items";
    JsonShapeValue => "json.shape-value", "a value";

    // Schema-generated form.
    SchemaUnrenderable => "schema.unrenderable", "This field cannot be shown here, so it has to be filled in some other way.";
    SchemaUnrenderableRequired => "schema.unrenderable-required", "This field is required and cannot be shown here, so this form cannot complete the call.";
    SchemaNoChoices => "schema.no-choices", "No choices were offered, so there is nothing to pick.";
    SchemaRequiredMissing => "schema.required-missing", "This field is required.";
    SchemaUnrenderableOne => "schema.unrenderable-one", "1 field cannot be shown here.";
    SchemaUnrenderableMany => "schema.unrenderable-many", "{0} fields cannot be shown here.";

    // Connections, and what each one offers.
    ServerConnected => "server.connected", "Connected";
    ServerConnecting => "server.connecting", "Connecting";
    ServerDisconnected => "server.disconnected", "Disconnected";
    ServerFailed => "server.failed", "Failed";
    ServerDisabled => "server.disabled", "Turned off";
    ServerTools => "server.tools", "Tools";
    ServerSkills => "server.skills", "Skills";
    ServerResources => "server.resources", "Resources";
    ServerOfferingsUnasked => "server.offerings-unasked", "Nothing has been asked for yet";
    ServerOfferingsUnaskedDetail => "server.offerings-unasked-detail", "This connection has not been asked what it offers.";
    ServerOfferingsAsking => "server.offerings-asking", "Asking what this connection offers";
    ServerOfferingsNone => "server.offerings-none", "This connection offers nothing";
    ServerOfferingsNoneDetail => "server.offerings-none-detail", "It answered, and the answer was empty.";
    ServerOfferingsUnavailable => "server.offerings-unavailable", "What this connection offers is unknown";
    ServerEmpty => "server.empty", "No connections";
    ServerEmptyDetail => "server.empty-detail", "Nothing has been connected yet.";

    // A run: one tool call, the steps it belongs to, and the reasoning beside
    // them. The state words are shared between the card and the list, because
    // a call that is running and a step that is running are one word to a
    // reader.
    AgentArguments => "agent.arguments", "Arguments";
    AgentResult => "agent.result", "Result";
    AgentPendingApproval => "agent.pending-approval", "Waiting for approval";
    AgentRunning => "agent.running", "Running";
    AgentSucceeded => "agent.succeeded", "Succeeded";
    AgentFailed => "agent.failed", "Failed";
    AgentDeclined => "agent.declined", "Declined";
    AgentPending => "agent.pending", "Pending";
    AgentDone => "agent.done", "Done";
    AgentSkipped => "agent.skipped", "Skipped";
    AgentNoOutput => "agent.no-output", "This tool returned nothing";
    AgentElapsedUnknown => "agent.elapsed-unknown", "Elapsed time unknown";
    AgentTruncated => "agent.truncated", "{0} of {1} lines shown";
    AgentLinesOne => "agent.lines-one", "1 line";
    AgentLinesMany => "agent.lines-many", "{0} lines";
    AgentStepsDoneOne => "agent.steps-done-one", "1 step done";
    AgentStepsDoneMany => "agent.steps-done-many", "{0} steps done";
    AgentReasoning => "agent.reasoning", "Reasoning";
    AgentReasoningWithheld => "agent.reasoning-withheld", "Withheld";
    AgentReasoningThinking => "agent.reasoning-thinking", "Thinking";
    AgentReasoningAbsent => "agent.reasoning-absent", "No reasoning was returned";

    // Document tabs. A clean tab carries no wording at all, which is why
    // there is no key here for one: silence is the whole message.
    TabDirty => "tab.dirty", "Unsaved changes";
    TabSaving => "tab.saving", "Saving";
    TabSaveFailed => "tab.save-failed", "Could not save";
    TabClose => "tab.close", "Close {0}";
    TabMoreTabs => "tab.more-tabs", "More tabs";

    // Search, and find and replace.
    SearchPlaceholder => "search.placeholder", "Find";
    SearchNoHits => "search.no-hits", "No results";
    SearchCounting => "search.counting", "Counting…";
    SearchNotSearched => "search.not-searched", "Nothing searched yet";
    SearchTooMany => "search.too-many", "More than {0}";
    SearchHitOne => "search.hit-one", "1 result";
    SearchHitMany => "search.hit-many", "{0} results";
    SearchNext => "search.next", "Next result";
    SearchPrevious => "search.previous", "Previous result";
    SearchCaseSensitive => "search.case-sensitive", "Match case";
    SearchWholeWord => "search.whole-word", "Whole word";
    ReplacePlaceholder => "replace.placeholder", "Replace with";
    ReplaceOne => "replace.one", "Replace";
    ReplaceAllCounted => "replace.all-counted", "Replace all {0}";
    ReplaceAllUncounted => "replace.all-uncounted", "Replace all";
    ReplaceAllUncountable => "replace.all-uncountable", "Nobody has counted the results yet, so this cannot say how many it would change.";

    // Notification centre.
    NotificationsTitle => "notifications.title", "Notifications";
    NotificationsEmpty => "notifications.empty", "Nothing to report";
    NotificationsEmptyDetail => "notifications.empty-detail", "Notifications that have come and gone are kept here.";
    NotificationsClearAll => "notifications.clear-all", "Clear all";
    NotificationsMarkAllRead => "notifications.mark-all-read", "Mark all as read";
    NotificationsUnread => "notifications.unread", "Unread";
    NotificationsUnreadCount => "notifications.unread-count", "{0} unread";
    NotificationsAtLeast => "notifications.at-least", "{0}+";

    // A panel whose contents the host could not produce.
    FailureTitle => "failure.title", "This panel could not be shown";
    FailureAttempts => "failure.attempts", "Tried {0} times";
    FailureRetrying => "failure.retrying", "Trying again";

    // Read-only code.
    CodeLineNumbers => "code.line-numbers", "Line numbers";
    CodeLineAdded => "code.line-added", "Added";
    CodeLineRemoved => "code.line-removed", "Removed";
    CodeLineChanged => "code.line-changed", "Changed";
    CodeLineHighlighted => "code.line-highlighted", "Highlighted";
    CodeLineError => "code.line-error", "Error";
    CodeEmpty => "code.empty", "Nothing to show";

    // Developer and data readings. Log metadata and metric values are caller
    // strings; only controls and state names belong to this catalogue.
    LogFollow => "log.follow", "Follow output";
    LogPause => "log.pause", "Pause output";
    LogFollowing => "log.following", "Following newest";
    LogPaused => "log.paused", "Follow paused";
    LogEmpty => "log.empty", "No log entries";
    LogUnavailable => "log.unavailable", "Log unavailable";
    LogError => "log.error", "Could not load log";
    DiffFile => "diff.file", "File";
    DiffHunk => "diff.hunk", "Hunk";
    DiffContextLine => "diff.context-line", "Context line";
    DiffChangedLine => "diff.changed-line", "Changed line";
    DiffEmpty => "diff.empty", "No differences";
    SparklineEmpty => "sparkline.empty", "No readings";
    SparklineUnavailable => "sparkline.unavailable", "Reading unavailable";
    SparklineError => "sparkline.error", "Could not load reading";
    SparklineCurrent => "sparkline.current", "Current: {0}";
    SparklineMinimum => "sparkline.minimum", "Minimum: {0}";
    SparklineMaximum => "sparkline.maximum", "Maximum: {0}";
    SparklineRange => "sparkline.range", "Minimum {0}; maximum {1}";

    // Uploads.
    UploadQueued => "upload.queued", "Queued";
    UploadUploading => "upload.uploading", "Uploading";
    UploadDone => "upload.done", "Uploaded";
    UploadFailed => "upload.failed", "Failed";
    UploadCancelled => "upload.cancelled", "Cancelled";
    UploadRefused => "upload.refused", "Not accepted";
    UploadCancel => "upload.cancel", "Cancel {0}";
    UploadRemove => "upload.remove", "Remove {0}";
    UploadOverall => "upload.overall", "Uploading";
    UploadEmpty => "upload.empty", "No files yet";

    // The web view shell. The four things that are not a page each say a
    // different thing, so each of them is its own key rather than one
    // "cannot show" a host would have to disambiguate by guessing.
    BrowserPanel => "browser.panel", "Browser";
    BrowserBack => "browser.back", "Back";
    BrowserForward => "browser.forward", "Forward";
    BrowserReload => "browser.reload", "Reload";
    BrowserNoAddress => "browser.no-address", "No address";
    BrowserEmpty => "browser.empty", "No page content";
    BrowserEmptyDetail => "browser.empty-detail", "The page loaded without content.";
    BrowserUnavailable => "browser.unavailable", "Web content unavailable";
    BrowserNoEngineDetail => "browser.no-engine-detail", "This build cannot display web content.";
    BrowserError => "browser.error", "Could not load";
    BrowserNoViewport => "browser.no-viewport", "No page surface";
    BrowserNoViewportDetail => "browser.no-viewport-detail",
        "The host reported a ready page but supplied no viewport.";

    // Offerings aggregated across caller-owned server sources.
    OfferingCatalogEmpty => "offering-catalog.empty", "No offerings";
    OfferingCatalogNoMatch => "offering-catalog.no-match", "No matching offerings";
    OfferingSourceLoading => "offering-source.loading", "{0}: Loading offerings";
    OfferingSourceEmpty => "offering-source.empty", "{0}: No offerings";
    OfferingSourceUnavailable => "offering-source.unavailable", "{0}: Offerings unavailable";
    OfferingSourceError => "offering-source.error", "{0}: Could not load offerings";
    OfferingSourceStale => "offering-source.stale", "{0}: Showing last verified offerings";
}

/// The catalogue a host installs, and the one components read.
///
/// It holds only the entries a host replaced. An absent entry is not a gap to
/// be filled at runtime; it means the English default stands, which is why a
/// partial catalogue is a legitimate thing to install rather than a mistake.
#[derive(Debug, Clone, Default)]
pub struct Strings {
    overrides: BTreeMap<StringKey, SharedString>,
}

impl Global for Strings {}

impl Strings {
    /// An empty catalogue: every key answers with its English.
    pub fn new() -> Self {
        Self::default()
    }

    /// Replaces one entry.
    pub fn set(&mut self, key: StringKey, text: impl Into<SharedString>) -> &mut Self {
        self.overrides.insert(key, text.into());
        self
    }

    /// Restores the English for one entry.
    pub fn clear(&mut self, key: StringKey) -> &mut Self {
        self.overrides.remove(&key);
        self
    }

    /// Restores the English for every entry.
    pub fn clear_all(&mut self) -> &mut Self {
        self.overrides.clear();
        self
    }

    /// Replaces many entries, leaving the rest alone.
    pub fn extend(
        &mut self,
        entries: impl IntoIterator<Item = (StringKey, SharedString)>,
    ) -> &mut Self {
        self.overrides.extend(entries);
        self
    }

    /// Whether this key was replaced. A component never asks; a test does.
    pub fn is_overridden(&self, key: StringKey) -> bool {
        self.overrides.contains_key(&key)
    }

    /// The text behind a key, which is always something a reader can read.
    pub fn text(&self, key: StringKey) -> SharedString {
        match self.overrides.get(&key) {
            Some(text) => text.clone(),
            None => SharedString::new_static(key.english()),
        }
    }

    /// The text behind a key with `{0}`, `{1}`, … replaced in place.
    ///
    /// A placeholder with no argument is left standing rather than removed, so
    /// a wrong translation reads as an obvious mistake instead of a sentence
    /// that quietly lost a fact.
    pub fn format(&self, key: StringKey, args: &[&str]) -> SharedString {
        SharedString::from(interpolate(self.text(key).as_ref(), args))
    }
}

fn interpolate(template: &str, args: &[&str]) -> String {
    let mut out = String::with_capacity(template.len());
    let mut rest = template;
    while let Some(open) = rest.find('{') {
        let (before, tail) = rest.split_at(open);
        out.push_str(before);
        let Some(close) = tail.find('}') else {
            out.push_str(tail);
            return out;
        };
        let slot = &tail[1..close];
        match slot.parse::<usize>().ok().and_then(|index| args.get(index)) {
            Some(value) => out.push_str(value),
            None => out.push_str(&tail[..=close]),
        }
        rest = &tail[close + 1..];
    }
    out.push_str(rest);
    out
}

/// Reads the installed catalogue from any context that dereferences to
/// [`App`], mirroring [`ActiveTheme`](gpui_kit_theme::ActiveTheme).
pub trait ActiveStrings {
    fn strings(&self) -> &Strings;
}

impl ActiveStrings for App {
    /// Falls back to the English catalogue when no host installed one, because
    /// a component that panicked or rendered blank for want of a global would
    /// be a worse library than one with English compiled in.
    fn strings(&self) -> &Strings {
        static ENGLISH: OnceLock<Strings> = OnceLock::new();
        self.try_global::<Strings>()
            .unwrap_or_else(|| ENGLISH.get_or_init(Strings::new))
    }
}

/// Installs the catalogue global. Idempotent, and never discards a catalogue a
/// host already installed.
pub fn install(cx: &mut App) {
    if !cx.has_global::<Strings>() {
        cx.set_global(Strings::new());
    }
}

/// Replaces entries and repaints every window, the way
/// [`activate_theme`](gpui_kit_theme::activate_theme) does.
pub fn set_strings(entries: impl IntoIterator<Item = (StringKey, SharedString)>, cx: &mut App) {
    install(cx);
    cx.update_global::<Strings, ()>(|strings, _| {
        strings.extend(entries);
    });
    cx.refresh_windows();
}

/// Restores the English for every entry and repaints every window.
pub fn reset_strings(cx: &mut App) {
    install(cx);
    cx.update_global::<Strings, ()>(|strings, _| {
        strings.clear_all();
    });
    cx.refresh_windows();
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_key_has_a_unique_name_and_english() {
        let mut names: Vec<&str> = StringKey::ALL.iter().map(|key| key.name()).collect();
        names.sort_unstable();
        let before = names.len();
        names.dedup();
        assert_eq!(names.len(), before, "two keys share a name");
        for key in StringKey::ALL {
            assert!(!key.english().is_empty(), "{} has no English", key.name());
            assert_eq!(StringKey::from_name(key.name()), Some(*key));
        }
    }

    #[test]
    fn an_empty_catalogue_answers_in_english() {
        let strings = Strings::new();
        assert_eq!(strings.text(StringKey::Copy), "Copy");
        assert_eq!(strings.text(StringKey::TryAgain), "Try again");
    }

    #[test]
    fn an_override_replaces_only_what_it_names() {
        let mut strings = Strings::new();
        strings.set(StringKey::Copy, "Kopieren");
        assert_eq!(strings.text(StringKey::Copy), "Kopieren");
        assert_eq!(strings.text(StringKey::TryAgain), "Try again");
        strings.clear(StringKey::Copy);
        assert_eq!(strings.text(StringKey::Copy), "Copy");
    }

    #[test]
    fn a_template_takes_its_arguments_in_any_order() {
        let mut strings = Strings::new();
        assert_eq!(
            strings.format(StringKey::RangeComplete, &["Monday", "Friday"]),
            "Monday to Friday."
        );
        strings.set(StringKey::RangeComplete, "{1} back to {0}.");
        assert_eq!(
            strings.format(StringKey::RangeComplete, &["Monday", "Friday"]),
            "Friday back to Monday."
        );
    }

    #[test]
    fn a_placeholder_with_no_argument_stays_visible() {
        let strings = Strings::new();
        assert_eq!(
            strings.format(StringKey::RangeComplete, &["Monday"]),
            "Monday to {1}."
        );
    }

    #[test]
    fn every_placeholder_in_the_english_is_numbered_from_zero() {
        for key in StringKey::ALL {
            let english = key.english();
            let mut expected = 0usize;
            let mut rest = english;
            while let Some(open) = rest.find('{') {
                let tail = &rest[open..];
                let close = tail.find('}').unwrap_or_else(|| {
                    panic!("{} has an unclosed placeholder", key.name());
                });
                let slot = &tail[1..close];
                assert_eq!(
                    slot.parse::<usize>().ok(),
                    Some(expected),
                    "{} numbers its placeholders out of order",
                    key.name()
                );
                expected += 1;
                rest = &tail[close + 1..];
            }
        }
    }
}