1use 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#[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#[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
165pub 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 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 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}