1use gpui::{
33 App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement,
34 KeyDownEvent, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window,
35 div, prelude::FluentBuilder, px,
36};
37use gpui_kit_assets::{Icon, icon};
38use gpui_kit_semantics::{NodeSpec, Role, Semantic};
39use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TypeScale};
40
41use crate::foundation::{
42 Disableable, FocusRing, Ident, Sizable, StyledExt, text as foundation_text,
43};
44use crate::overlay::Kbd;
45use crate::strings::{ActiveStrings, StringKey};
46
47const KEY_CONTEXT: &str = "KeybindingRecorder";
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum KeybindingRecorderEvent {
54 Started,
56 Captured(SharedString),
58 Cancelled,
60}
61
62impl EventEmitter<KeybindingRecorderEvent> for KeybindingRecorder {}
63
64pub struct KeybindingRecorder {
71 ident: Ident,
72 focus_handle: FocusHandle,
73 label: Option<SharedString>,
74 placeholder: Option<SharedString>,
75 binding: Option<SharedString>,
76 conflict: Option<SharedString>,
77 allow_escape: bool,
78 size: ControlSize,
79 disabled: bool,
80 recording: bool,
81}
82
83impl std::fmt::Debug for KeybindingRecorder {
84 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 formatter
86 .debug_struct("KeybindingRecorder")
87 .field("ident", &self.ident)
88 .field("binding", &self.binding)
89 .field("recording", &self.recording)
90 .field("conflict", &self.conflict)
91 .field("disabled", &self.disabled)
92 .finish()
93 }
94}
95
96impl KeybindingRecorder {
97 pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
98 Self {
99 ident: ident.into(),
100 focus_handle: cx.focus_handle(),
101 label: None,
102 placeholder: None,
103 binding: None,
104 conflict: None,
105 allow_escape: false,
106 size: ControlSize::Md,
107 disabled: false,
108 recording: false,
109 }
110 }
111
112 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
114 self.label = Some(label.into());
115 self
116 }
117
118 fn resolved_placeholder(&self, cx: &App) -> SharedString {
122 self.placeholder
123 .clone()
124 .unwrap_or_else(|| cx.strings().text(StringKey::KeybindingUnbound))
125 }
126
127 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
128 self.placeholder = Some(placeholder.into());
129 self
130 }
131
132 pub fn binding(mut self, binding: impl Into<SharedString>) -> Self {
134 self.binding = Some(binding.into());
135 self
136 }
137
138 pub fn set_binding(&mut self, binding: Option<SharedString>, cx: &mut Context<Self>) {
139 self.binding = binding;
140 cx.notify();
141 }
142
143 pub fn conflict(mut self, reason: Option<impl Into<SharedString>>) -> Self {
147 self.conflict = reason.map(Into::into);
148 self
149 }
150
151 pub fn set_conflict(&mut self, reason: Option<SharedString>, cx: &mut Context<Self>) {
152 self.conflict = reason;
153 cx.notify();
154 }
155
156 pub fn allow_escape(mut self, allow: bool) -> Self {
161 self.allow_escape = allow;
162 self
163 }
164
165 pub fn is_recording(&self) -> bool {
166 self.recording
167 }
168
169 pub fn current_binding(&self) -> Option<&SharedString> {
170 self.binding.as_ref()
171 }
172
173 pub fn start(&mut self, window: &mut Window, cx: &mut Context<Self>) {
175 if self.disabled || self.recording {
176 return;
177 }
178 self.recording = true;
179 window.focus(&self.focus_handle, cx);
180 cx.emit(KeybindingRecorderEvent::Started);
181 cx.notify();
182 }
183
184 pub fn cancel(&mut self, cx: &mut Context<Self>) {
186 if !self.recording {
187 return;
188 }
189 self.recording = false;
190 cx.emit(KeybindingRecorderEvent::Cancelled);
191 cx.notify();
192 }
193
194 fn capture(&mut self, event: &KeyDownEvent, cx: &mut Context<Self>) -> bool {
195 if !self.recording {
196 return false;
197 }
198 let key = event.keystroke.key.as_str();
199 if key == "escape" && !self.allow_escape {
200 self.recording = false;
201 cx.emit(KeybindingRecorderEvent::Cancelled);
202 cx.notify();
203 return true;
204 }
205 if is_modifier(key) {
208 return true;
209 }
210 self.recording = false;
211 cx.emit(KeybindingRecorderEvent::Captured(SharedString::from(
212 event.keystroke.unparse(),
213 )));
214 cx.notify();
215 true
216 }
217}
218
219impl Disableable for KeybindingRecorder {
220 fn disabled(mut self, disabled: bool) -> Self {
222 self.disabled = disabled;
223 self
224 }
225}
226
227impl Sizable for KeybindingRecorder {
228 fn control_size(mut self, size: ControlSize) -> Self {
229 self.size = size;
230 self
231 }
232}
233
234impl Focusable for KeybindingRecorder {
235 fn focus_handle(&self, _cx: &App) -> FocusHandle {
236 self.focus_handle.clone()
237 }
238}
239
240pub fn is_modifier(key: &str) -> bool {
246 matches!(
247 key,
248 "shift"
249 | "control"
250 | "ctrl"
251 | "alt"
252 | "option"
253 | "cmd"
254 | "command"
255 | "super"
256 | "win"
257 | "platform"
258 | "function"
259 | "fn"
260 )
261}
262
263impl Render for KeybindingRecorder {
264 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
265 let theme = cx.theme().clone();
266 let metrics = theme.control.get(self.size);
267 let recording = self.recording && !self.disabled;
268 let conflicted = self.conflict.is_some();
269 let actionable = !self.disabled;
270
271 let (border, background) = if recording {
272 (theme.colors.accent, theme.colors.accent.opacity(0.12))
273 } else if conflicted {
274 (theme.colors.danger, theme.colors.danger.opacity(0.08))
275 } else {
276 (theme.colors.hairline, theme.colors.panel)
277 };
278
279 let body = if recording {
282 div()
283 .row()
284 .gap_token(&theme, Space::Xs)
285 .text_color(theme.colors.accent)
286 .child(
287 icon(Icon::Keyboard)
288 .size(px(metrics.icon_size))
289 .text_color(theme.colors.accent),
290 )
291 .child(
292 foundation_text(
293 &theme,
294 TypeScale::Label,
295 cx.strings().text(StringKey::KeybindingPrompt),
296 )
297 .text_size(px(metrics.font_size))
298 .text_color(theme.colors.accent),
299 )
300 .into_any_element()
301 } else {
302 match self.binding.clone() {
303 Some(binding) => div()
304 .row()
305 .gap_token(&theme, Space::Xs)
306 .child(Kbd::new(binding).id(self.ident.child("keys")))
307 .into_any_element(),
308 None => foundation_text(&theme, TypeScale::Label, self.resolved_placeholder(cx))
309 .text_size(px(metrics.font_size))
310 .text_tone(&theme, gpui_kit_theme::TextTone::Faint)
311 .into_any_element(),
312 }
313 };
314
315 let mut field = div()
316 .id(self.ident.element_id())
317 .key_context(KEY_CONTEXT)
318 .track_focus(&self.focus_handle)
319 .row()
320 .h(px(metrics.height))
321 .min_w(px(160.0))
322 .px(px(metrics.padding_x))
323 .gap(px(metrics.gap))
324 .items_center()
325 .radius(&theme, Radius::Control)
326 .border(px(if recording {
327 theme.borders.thick
328 } else {
329 theme.borders.hairline
330 }))
331 .border_color(border)
332 .bg(background)
333 .text_size(px(metrics.font_size))
334 .text_color(theme.colors.text)
335 .when(self.disabled, |element| {
336 element.opacity(theme.opacity.disabled)
337 })
338 .when(actionable, |element| {
339 element
340 .cursor_pointer()
341 .tab_index(0)
342 .hover(|style| style.border_color(theme.colors.hairline_strong))
343 .focus_ring(&theme)
344 })
345 .child(body);
346
347 if actionable {
348 field = field
349 .on_click(cx.listener(|recorder, _, window, cx| recorder.start(window, cx)))
350 .on_key_down(cx.listener(|recorder, event: &KeyDownEvent, _, cx| {
351 if recorder.capture(event, cx) {
354 cx.stop_propagation();
355 return;
356 }
357 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
358 recorder.recording = true;
359 cx.emit(KeybindingRecorderEvent::Started);
360 cx.notify();
361 cx.stop_propagation();
362 }
363 }));
364 }
365
366 let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Input)
367 .focus(&self.focus_handle)
368 .disabled(self.disabled)
369 .busy(recording)
370 .invalid(conflicted)
371 .placeholder(self.resolved_placeholder(cx));
372 if let Some(label) = self.label.clone() {
373 spec = spec.text(label);
374 }
375 if recording {
378 spec = spec.value("recording");
379 } else if let Some(binding) = self.binding.clone() {
380 spec = spec.value(binding);
381 }
382 let published = field.semantic_in(cx, spec);
383
384 let conflict = self.conflict.clone().map(|reason| {
385 let ident = self.ident.child("conflict");
386 div()
387 .row()
388 .gap_token(&theme, Space::Xs)
389 .child(
390 icon(Icon::Danger)
391 .size(px(11.0))
392 .text_color(theme.colors.danger),
393 )
394 .child(
395 foundation_text(&theme, TypeScale::Caption, reason.clone())
396 .text_color(theme.colors.danger),
397 )
398 .semantic_in(
399 cx,
400 NodeSpec::new(ident.semantic_id(), Role::Status)
401 .parent(self.ident.semantic_id())
402 .invalid(true)
403 .text(reason),
404 )
405 });
406
407 div()
408 .column()
409 .gap_token(&theme, Space::Xs)
410 .child(published)
411 .children(conflict)
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use gpui::Keystroke;
419
420 fn round_trips(source: &str) {
423 let keystroke = Keystroke::parse(source).expect("gpui parses its own syntax");
424 let reported = keystroke.unparse();
425 let read_back = Keystroke::parse(&reported).expect("gpui reads what the recorder reports");
426 assert_eq!(read_back.modifiers, keystroke.modifiers, "for {source}");
427 assert_eq!(read_back.key, keystroke.key, "for {source}");
428 }
429
430 #[test]
431 fn what_the_recorder_reports_is_what_gpui_parses() {
432 for source in [
433 "cmd-shift-p",
434 "ctrl-alt-delete",
435 "f5",
436 "shift-tab",
437 "alt-enter",
438 "ctrl-,",
439 "P",
440 ] {
441 round_trips(source);
442 }
443 }
444
445 #[test]
446 fn a_capital_letter_is_reported_as_shift_and_a_lowercase_key() {
447 let keystroke = Keystroke::parse("P").expect("parses");
448 assert_eq!(keystroke.key, "p");
449 assert!(keystroke.modifiers.shift);
450 assert_eq!(keystroke.unparse(), "shift-p");
451 }
452
453 #[test]
454 fn what_the_recorder_reports_is_what_kbd_draws() {
455 let keystroke = Keystroke::parse("cmd-shift-p").expect("parses");
456 let caps =
457 crate::overlay::caps(&keystroke.unparse(), true, &crate::strings::Strings::new());
458 assert_eq!(caps, vec![SharedString::from("⌘⇧P")]);
459 }
460
461 #[test]
462 fn a_bare_modifier_is_not_a_keystroke() {
463 for key in ["shift", "control", "alt", "platform", "function"] {
464 assert!(is_modifier(key), "{key} is a modifier");
465 assert_eq!(Keystroke::parse(key).expect("parses").key, key);
467 }
468 assert!(!is_modifier("p"));
469 assert!(!is_modifier("escape"));
470 }
471}