Skip to main content

gpui_kit/controls/
keymap_editor.rs

1//! Caller-owned keymap facts with transient, shared shortcut recording.
2
3use gpui::{
4    AppContext, Context, Entity, EventEmitter, InteractiveElement, IntoElement, ParentElement,
5    Render, SharedString, Styled, Subscription, Window, div, px,
6};
7use gpui_kit_semantics::{NodeSpec, Role, Semantic};
8use gpui_kit_theme::{ActiveTheme, Space, TypeScale};
9
10use crate::controls::button::Button;
11use crate::controls::keybinding_recorder::{KeybindingRecorder, KeybindingRecorderEvent};
12use crate::foundation::{Disableable, Ident, StyledExt, text as foundation_text};
13use crate::overlay::Kbd;
14use crate::strings::{ActiveStrings, StringKey};
15
16/// One effective binding, identified independently of its position.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct KeymapBinding {
19    id: SharedString,
20    keystroke: SharedString,
21    conflict: Option<SharedString>,
22    provenance: Option<SharedString>,
23}
24
25impl KeymapBinding {
26    pub fn new(id: impl Into<SharedString>, keystroke: impl Into<SharedString>) -> Self {
27        Self {
28            id: id.into(),
29            keystroke: keystroke.into(),
30            conflict: None,
31            provenance: None,
32        }
33    }
34    pub fn conflict(mut self, value: impl Into<SharedString>) -> Self {
35        self.conflict = Some(value.into());
36        self
37    }
38    pub fn provenance(mut self, value: impl Into<SharedString>) -> Self {
39        self.provenance = Some(value.into());
40        self
41    }
42
43    pub fn id(&self) -> &SharedString {
44        &self.id
45    }
46
47    pub fn keystroke(&self) -> &SharedString {
48        &self.keystroke
49    }
50
51    pub fn conflict_reason(&self) -> Option<&SharedString> {
52        self.conflict.as_ref()
53    }
54
55    pub fn provenance_label(&self) -> Option<&SharedString> {
56        self.provenance.as_ref()
57    }
58}
59
60/// Caller-owned command metadata and binding facts.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct KeymapCommand {
63    id: SharedString,
64    label: SharedString,
65    context: Option<SharedString>,
66    default_bindings: Vec<SharedString>,
67    effective_bindings: Vec<KeymapBinding>,
68    search_text: SharedString,
69    keywords: Vec<SharedString>,
70    refusal: Option<SharedString>,
71}
72
73impl KeymapCommand {
74    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
75        Self {
76            id: id.into(),
77            label: label.into(),
78            context: None,
79            default_bindings: vec![],
80            effective_bindings: vec![],
81            search_text: "".into(),
82            keywords: vec![],
83            refusal: None,
84        }
85    }
86    pub fn context(mut self, value: impl Into<SharedString>) -> Self {
87        self.context = Some(value.into());
88        self
89    }
90    pub fn defaults(mut self, values: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
91        self.default_bindings = values.into_iter().map(Into::into).collect();
92        self
93    }
94    pub fn bindings(mut self, values: impl IntoIterator<Item = KeymapBinding>) -> Self {
95        self.effective_bindings = values.into_iter().collect();
96        self
97    }
98    pub fn searchable(
99        mut self,
100        text: impl Into<SharedString>,
101        keywords: impl IntoIterator<Item = impl Into<SharedString>>,
102    ) -> Self {
103        self.search_text = text.into();
104        self.keywords = keywords.into_iter().map(Into::into).collect();
105        self
106    }
107    pub fn refused(mut self, reason: impl Into<SharedString>) -> Self {
108        self.refusal = Some(reason.into());
109        self
110    }
111
112    pub fn id(&self) -> &SharedString {
113        &self.id
114    }
115
116    pub fn label_text(&self) -> &SharedString {
117        &self.label
118    }
119
120    pub fn context_label(&self) -> Option<&SharedString> {
121        self.context.as_ref()
122    }
123
124    pub fn default_bindings(&self) -> &[SharedString] {
125        &self.default_bindings
126    }
127
128    pub fn effective_bindings(&self) -> &[KeymapBinding] {
129        &self.effective_bindings
130    }
131
132    pub fn search_text(&self) -> &SharedString {
133        &self.search_text
134    }
135
136    pub fn keywords(&self) -> &[SharedString] {
137        &self.keywords
138    }
139
140    pub fn refusal_reason(&self) -> Option<&SharedString> {
141        self.refusal.as_ref()
142    }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum KeymapEditorEvent {
147    AddCaptured {
148        command_id: SharedString,
149        keystroke: SharedString,
150    },
151    Remove {
152        command_id: SharedString,
153        binding_id: SharedString,
154    },
155    Reset {
156        command_id: SharedString,
157    },
158    RecordingCancelled {
159        command_id: SharedString,
160    },
161}
162
163impl EventEmitter<KeymapEditorEvent> for KeymapEditor {}
164
165/// Coordinates one recorder over caller-owned keymap rows; it never applies a binding.
166pub struct KeymapEditor {
167    ident: Ident,
168    commands: Vec<KeymapCommand>,
169    query: SharedString,
170    disabled: bool,
171    active_command: Option<SharedString>,
172    suppress_next_recorder_cancel: bool,
173    recorder: Entity<KeybindingRecorder>,
174    _recorder_subscription: Subscription,
175}
176
177impl std::fmt::Debug for KeymapEditor {
178    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        formatter
180            .debug_struct("KeymapEditor")
181            .field("ident", &self.ident)
182            .field("commands", &self.commands)
183            .field("query", &self.query)
184            .field("disabled", &self.disabled)
185            .field("active_command", &self.active_command)
186            .finish_non_exhaustive()
187    }
188}
189
190impl KeymapEditor {
191    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
192        let ident = ident.into();
193        let recorder = cx.new(|cx| {
194            KeybindingRecorder::new(ident.child("recorder"), window, cx)
195                .label(cx.strings().text(StringKey::KeymapAdd))
196        });
197        let subscription = cx.subscribe(&recorder, |this, _, event, cx| {
198            match event {
199                KeybindingRecorderEvent::Captured(keystroke) => {
200                    let Some(command_id) = this.active_command.take() else {
201                        return;
202                    };
203                    cx.emit(KeymapEditorEvent::AddCaptured {
204                        command_id,
205                        keystroke: keystroke.clone(),
206                    })
207                }
208                KeybindingRecorderEvent::Cancelled => {
209                    if this.suppress_next_recorder_cancel {
210                        this.suppress_next_recorder_cancel = false;
211                        return;
212                    }
213                    let Some(command_id) = this.active_command.take() else {
214                        return;
215                    };
216                    cx.emit(KeymapEditorEvent::RecordingCancelled { command_id })
217                }
218                KeybindingRecorderEvent::Started => return,
219            }
220            cx.notify();
221        });
222        Self {
223            ident,
224            commands: vec![],
225            query: "".into(),
226            disabled: false,
227            active_command: None,
228            suppress_next_recorder_cancel: false,
229            recorder,
230            _recorder_subscription: subscription,
231        }
232    }
233
234    pub fn commands(mut self, commands: impl IntoIterator<Item = KeymapCommand>) -> Self {
235        self.commands = commands.into_iter().collect();
236        self
237    }
238    pub fn query(mut self, query: impl Into<SharedString>) -> Self {
239        self.query = query.into();
240        self
241    }
242    pub fn set_commands(&mut self, commands: Vec<KeymapCommand>, cx: &mut Context<Self>) {
243        self.commands = commands;
244        self.cancel_if_active_is_hidden(cx);
245        cx.notify();
246    }
247    pub fn set_query(&mut self, query: impl Into<SharedString>, cx: &mut Context<Self>) {
248        self.query = query.into();
249        self.cancel_if_active_is_hidden(cx);
250        cx.notify();
251    }
252
253    /// Refuses every editor action without removing caller-owned bindings.
254    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
255        self.disabled = disabled;
256        if disabled {
257            self.recorder.update(cx, |recorder, cx| recorder.cancel(cx));
258        }
259        cx.notify();
260    }
261
262    pub fn active_command(&self) -> Option<&SharedString> {
263        self.active_command.as_ref()
264    }
265
266    pub fn current_commands(&self) -> &[KeymapCommand] {
267        &self.commands
268    }
269
270    fn matches(&self, command: &KeymapCommand) -> bool {
271        let query = self.query.to_lowercase();
272        query.is_empty()
273            || [&command.id, &command.label, &command.search_text]
274                .into_iter()
275                .chain(command.context.iter())
276                .chain(command.keywords.iter())
277                .any(|value| value.to_lowercase().contains(&query))
278    }
279
280    fn cancel_if_active_is_hidden(&mut self, cx: &mut Context<Self>) {
281        let visible = self.active_command.as_ref().is_none_or(|active| {
282            self.commands.iter().any(|command| {
283                &command.id == active && command.refusal.is_none() && self.matches(command)
284            })
285        });
286        if !visible {
287            self.recorder.update(cx, |recorder, cx| recorder.cancel(cx));
288        }
289    }
290}
291
292impl Disableable for KeymapEditor {
293    fn disabled(mut self, disabled: bool) -> Self {
294        self.disabled = disabled;
295        self
296    }
297}
298
299impl Render for KeymapEditor {
300    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
301        let theme = cx.theme().clone();
302        let visible: Vec<_> = self
303            .commands
304            .iter()
305            .filter(|command| self.matches(command))
306            .cloned()
307            .collect();
308        let count = visible.len();
309        let root_id = self.ident.semantic_id();
310        let disabled = self.disabled;
311        let entity = cx.entity().clone();
312        div()
313            .id(self.ident.element_id())
314            .column()
315            .gap_token(&theme, Space::Md)
316            .child(
317                foundation_text(
318                    &theme,
319                    TypeScale::Caption,
320                    cx.strings()
321                        .format(StringKey::KeymapResultCount, &[&count.to_string()]),
322                )
323                .text_tone(&theme, gpui_kit_theme::TextTone::Muted)
324                .semantic_in(
325                    cx,
326                    NodeSpec::new(self.ident.child("status").semantic_id(), Role::Status)
327                        .parent(root_id.clone())
328                        .value(count.to_string()),
329                ),
330            )
331            .children(visible.into_iter().map(|command| {
332                let row = self.ident.child(command.id.as_ref());
333                let refused = command.refusal.is_some();
334                let actionable = !disabled && !refused;
335                let active = self.active_command.as_ref() == Some(&command.id);
336                let effective: Vec<_> = command
337                    .effective_bindings
338                    .iter()
339                    .map(|binding| binding.keystroke.clone())
340                    .collect();
341                let changed = effective != command.default_bindings;
342                let mut actions = div().row().gap_token(&theme, Space::Xs);
343                if actionable {
344                    let target = command.id.clone();
345                    let editor = entity.clone();
346                    actions = actions.child(
347                        Button::new(row.child("add"))
348                            .label(cx.strings().text(StringKey::KeymapAdd))
349                            .ghost()
350                            .semantic_parent(row.semantic_id())
351                            .on_click(move |window, cx| {
352                                editor.update(cx, |this, cx| {
353                                    if let Some(command_id) = this.active_command.take() {
354                                        this.suppress_next_recorder_cancel = true;
355                                        this.recorder.update(cx, |field, cx| field.cancel(cx));
356                                        cx.emit(KeymapEditorEvent::RecordingCancelled {
357                                            command_id,
358                                        });
359                                    }
360                                    this.active_command = Some(target.clone());
361                                    this.recorder
362                                        .update(cx, |field, cx| field.start(window, cx));
363                                    cx.notify();
364                                });
365                            }),
366                    );
367                    if changed {
368                        let target = command.id.clone();
369                        let editor = entity.clone();
370                        actions = actions.child(
371                            Button::new(row.child("reset"))
372                                .label(cx.strings().text(StringKey::KeymapReset))
373                                .ghost()
374                                .semantic_parent(row.semantic_id())
375                                .on_click(move |_, cx| {
376                                    editor.update(cx, |_, cx| {
377                                        cx.emit(KeymapEditorEvent::Reset {
378                                            command_id: target.clone(),
379                                        })
380                                    })
381                                }),
382                        );
383                    }
384                }
385                let bindings = command.effective_bindings.iter().map(|binding| {
386                    let binding_suffix = format!("binding.{}", binding.id);
387                    let binding_id = row.child(binding_suffix);
388                    let mut spec = NodeSpec::new(binding_id.semantic_id(), Role::Group)
389                        .parent(row.semantic_id())
390                        .value(binding.keystroke.clone());
391                    let description = [binding.conflict.clone(), binding.provenance.clone()]
392                        .into_iter()
393                        .flatten()
394                        .collect::<Vec<_>>()
395                        .join("; ");
396                    if !description.is_empty() {
397                        spec = spec.description(description);
398                    }
399                    let mut line = div()
400                        .row()
401                        .w_full()
402                        .items_center()
403                        .gap_token(&theme, Space::Sm)
404                        .child(Kbd::new(binding.keystroke.clone()).id(binding_id.child("keys")))
405                        .child(
406                            div()
407                                .row()
408                                .flex_1()
409                                .min_w_0()
410                                .gap_token(&theme, Space::Sm)
411                                .children(binding.conflict.clone().map(|reason| {
412                                    foundation_text(&theme, TypeScale::Body, reason.clone())
413                                        .text_color(theme.colors.danger)
414                                        .semantic_in(
415                                            cx,
416                                            NodeSpec::new(
417                                                binding_id.child("conflict").semantic_id(),
418                                                Role::Status,
419                                            )
420                                            .parent(binding_id.semantic_id())
421                                            .invalid(true)
422                                            .text(reason),
423                                        )
424                                }))
425                                .children(binding.provenance.clone().map(|provenance| {
426                                    foundation_text(&theme, TypeScale::Body, provenance.clone())
427                                        .text_tone(&theme, gpui_kit_theme::TextTone::Muted)
428                                        .semantic_in(
429                                            cx,
430                                            NodeSpec::new(
431                                                binding_id.child("provenance").semantic_id(),
432                                                Role::Status,
433                                            )
434                                            .parent(binding_id.semantic_id())
435                                            .text(provenance),
436                                        )
437                                })),
438                        );
439                    if actionable {
440                        let editor = entity.clone();
441                        let command_id = command.id.clone();
442                        let id = binding.id.clone();
443                        line = line.child(
444                            Button::new(binding_id.child("remove"))
445                                .label(cx.strings().text(StringKey::KeymapRemove))
446                                .ghost()
447                                .semantic_parent(binding_id.semantic_id())
448                                .on_click(move |_, cx| {
449                                    editor.update(cx, |_, cx| {
450                                        cx.emit(KeymapEditorEvent::Remove {
451                                            command_id: command_id.clone(),
452                                            binding_id: id.clone(),
453                                        })
454                                    })
455                                }),
456                        );
457                    }
458                    line.semantic_in(cx, spec)
459                });
460                let effective_value = if command.effective_bindings.is_empty() {
461                    cx.strings().text(StringKey::KeybindingUnbound)
462                } else {
463                    SharedString::from(
464                        command
465                            .effective_bindings
466                            .iter()
467                            .map(|binding| binding.keystroke.as_ref())
468                            .collect::<Vec<_>>()
469                            .join(", "),
470                    )
471                };
472                let defaults_value = if command.default_bindings.is_empty() {
473                    cx.strings().text(StringKey::KeybindingUnbound)
474                } else {
475                    SharedString::from(
476                        command
477                            .default_bindings
478                            .iter()
479                            .map(SharedString::as_ref)
480                            .collect::<Vec<_>>()
481                            .join(", "),
482                    )
483                };
484                let defaults = command.default_bindings.iter().cloned().map(Kbd::new);
485                div()
486                    .column()
487                    .gap_token(&theme, Space::Sm)
488                    .p(px(theme.space(Space::Sm)))
489                    .child(
490                        div()
491                            .row()
492                            .items_start()
493                            .justify_between()
494                            .child(
495                                div()
496                                    .column()
497                                    .child(foundation_text(
498                                        &theme,
499                                        TypeScale::Subtitle,
500                                        command.label.clone(),
501                                    ))
502                                    .children(command.context.clone().map(|context| {
503                                        foundation_text(&theme, TypeScale::Body, context)
504                                            .text_tone(&theme, gpui_kit_theme::TextTone::Muted)
505                                    })),
506                            )
507                            .child(actions),
508                    )
509                    .child(
510                        div()
511                            .id(row.child("effective").element_id())
512                            .column()
513                            .gap_token(&theme, Space::Xs)
514                            .child(foundation_text(
515                                &theme,
516                                TypeScale::Label,
517                                cx.strings().text(StringKey::KeymapEffective),
518                            ))
519                            .children((command.effective_bindings.is_empty()).then(|| {
520                                foundation_text(&theme, TypeScale::Body, effective_value.clone())
521                            }))
522                            .children(bindings)
523                            .semantic_in(
524                                cx,
525                                NodeSpec::new(row.child("effective").semantic_id(), Role::Group)
526                                    .parent(row.semantic_id())
527                                    .value(effective_value),
528                            ),
529                    )
530                    .child(
531                        div()
532                            .id(row.child("defaults").element_id())
533                            .row()
534                            .items_center()
535                            .gap_token(&theme, Space::Xs)
536                            .child(foundation_text(
537                                &theme,
538                                TypeScale::Body,
539                                cx.strings().text(StringKey::KeymapDefaults),
540                            ))
541                            .children(defaults)
542                            .semantic_in(
543                                cx,
544                                NodeSpec::new(row.child("defaults").semantic_id(), Role::Group)
545                                    .parent(row.semantic_id())
546                                    .value(defaults_value),
547                            ),
548                    )
549                    .children(command.refusal.clone().map(|reason| {
550                        foundation_text(&theme, TypeScale::Body, reason.clone()).semantic_in(
551                            cx,
552                            NodeSpec::new(row.child("refusal").semantic_id(), Role::Status)
553                                .parent(row.semantic_id())
554                                .text(reason),
555                        )
556                    }))
557                    .children(active.then(|| self.recorder.clone()))
558                    .semantic_in(
559                        cx,
560                        command
561                            .context
562                            .clone()
563                            .map_or_else(
564                                || NodeSpec::new(row.semantic_id(), Role::Row),
565                                |context| {
566                                    NodeSpec::new(row.semantic_id(), Role::Row).description(context)
567                                },
568                            )
569                            .parent(root_id.clone())
570                            .text(command.label)
571                            .disabled(disabled || refused),
572                    )
573            }))
574            .semantic_in(cx, NodeSpec::new(root_id, Role::Group).disabled(disabled))
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    fn command() -> KeymapCommand {
583        KeymapCommand::new("workbench.open", "Open item")
584            .context("Workspace")
585            .defaults(["cmd-o", "ctrl-o"])
586            .bindings([
587                KeymapBinding::new("primary", "cmd-shift-o")
588                    .conflict("Already assigned")
589                    .provenance("User keymap"),
590                KeymapBinding::new("alternate", "ctrl-o"),
591            ])
592            .searchable("open a workspace item", ["file", "picker"])
593    }
594
595    #[test]
596    fn model_preserves_all_caller_owned_binding_facts() {
597        let command = command();
598        assert_eq!(command.default_bindings(), ["cmd-o", "ctrl-o"]);
599        assert_eq!(command.effective_bindings().len(), 2);
600        assert_eq!(
601            command.effective_bindings()[0]
602                .conflict_reason()
603                .map(SharedString::as_ref),
604            Some("Already assigned")
605        );
606        assert_eq!(
607            command.effective_bindings()[0]
608                .provenance_label()
609                .map(SharedString::as_ref),
610            Some("User keymap")
611        );
612        assert_ne!(
613            command
614                .effective_bindings()
615                .iter()
616                .map(KeymapBinding::keystroke)
617                .collect::<Vec<_>>(),
618            command.default_bindings().iter().collect::<Vec<_>>()
619        );
620    }
621
622    #[test]
623    fn filtering_uses_deliberately_supplied_metadata_case_insensitively() {
624        // Construct only enough editor state to test its product-neutral predicate.
625        let matches = |query: &str| {
626            let query = query.to_lowercase();
627            let command = command();
628            query.is_empty()
629                || [command.id(), command.label_text(), command.search_text()]
630                    .into_iter()
631                    .chain(command.context_label())
632                    .chain(command.keywords())
633                    .any(|value| value.to_lowercase().contains(&query))
634        };
635        for query in ["WORKBENCH", "item", "workspace", "file", "picker"] {
636            assert!(matches(query), "{query}");
637        }
638        assert!(!matches("terminal"));
639    }
640}