Skip to main content

gpui_kit/controls/
keybinding_recorder.rs

1//! A field that captures the next keystroke instead of acting on it.
2//!
3//! # The syntax it produces
4//!
5//! The recorder reports GPUI's own keystroke syntax, not a private one: it
6//! hands back [`gpui::Keystroke::unparse`], which is exactly what
7//! [`gpui::Keystroke::parse`] reads. A captured binding therefore goes
8//! straight into a keymap, and straight into [`Kbd`], which splits the same
9//! string. The shape is `[fn-][ctrl-][alt-][cmd-|super-|win-][shift-]key`,
10//! where `key` is the lowercase name of the key — `cmd-shift-p`,
11//! `ctrl-alt-delete`, `f5`.
12//!
13//! # Escape
14//!
15//! Escape ends recording without capturing. That is the honest limit: escape
16//! is how every other surface in this library abandons something in flight,
17//! and a recorder that swallowed it would leave the typist with no way out of
18//! a field that eats every key. The cost is that **escape cannot be bound**
19//! through the default recorder. A caller that genuinely needs it turns
20//! [`KeybindingRecorder::allow_escape`] on and provides its own way out.
21//!
22//! # What it does not do
23//!
24//! - A modifier on its own is not a keystroke. The recorder keeps waiting
25//!   rather than reporting `shift` as a binding.
26//! - A captured binding is **reported**, never applied. The caller owns the
27//!   keymap, as it owns every other value in this library.
28//! - A conflict is the host's judgement. The recorder has no keymap to consult
29//!   and never guesses; [`KeybindingRecorder::conflict`] renders the reason the
30//!   host found, and nothing else.
31
32use 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
47/// The key context the recorder claims while it is capturing, so a host can
48/// see in the inspector which element is eating its keys.
49const KEY_CONTEXT: &str = "KeybindingRecorder";
50
51/// What a [`KeybindingRecorder`] reports.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum KeybindingRecorderEvent {
54    /// Recording began. Every keystroke now goes to the recorder.
55    Started,
56    /// A keystroke was captured, in GPUI's syntax. Nothing has been bound.
57    Captured(SharedString),
58    /// Recording ended without a keystroke.
59    Cancelled,
60}
61
62impl EventEmitter<KeybindingRecorderEvent> for KeybindingRecorder {}
63
64/// A field that records one keystroke.
65///
66/// Whether recording is in flight is transient view state, so this is a view
67/// rather than a builder. The binding itself is not: the recorder reports the
68/// keystroke that was pressed and renders whatever the caller says is bound,
69/// so a host that refuses a binding keeps showing the one that still holds.
70pub 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    /// What the binding is for, for a reader that has only the tree.
113    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
114        self.label = Some(label.into());
115        self
116    }
117
118    /// What to show where a binding would be, when there is none.
119    /// The placeholder the host gave, or the built-in default word for a
120    /// value that is not there.
121    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    /// The binding the caller says is current, in GPUI's syntax.
133    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    /// The conflict the **host** found, in its own words.
144    ///
145    /// The recorder has no keymap and never decides that two bindings clash.
146    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    /// Lets escape be captured as a binding rather than ending recording.
157    ///
158    /// A caller that turns this on owes the typist another way out, because
159    /// the usual one is now a keystroke the recorder eats.
160    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    /// Begins recording and takes the keyboard.
174    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    /// Ends recording without capturing anything.
185    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        // A modifier on its own is a hand resting on the keyboard, not a
206        // binding, so the recorder keeps waiting for the key it modifies.
207        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    /// Refuses the recorder. A refused recorder installs no handler at all.
221    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
240/// Whether a key name is a modifier rather than a key.
241///
242/// GPUI names a bare modifier `shift`, `control`, `alt`, `platform`, or
243/// `function` when it parses one; platforms deliver the more familiar spelling
244/// of the same keys, so both are refused.
245pub 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        // Recording has to be unmistakable: a recorder that looks like a text
280        // field invites someone to type into it and lose whatever they typed.
281        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                    // While recording, the keystroke belongs to the recorder
352                    // and to nothing else on the way up.
353                    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        // A recorder in flight says so where its value would be: the value is
376        // what the caller bound, and nothing is bound while it is listening.
377        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    /// The one property that matters: whatever the recorder reports, GPUI
421    /// reads back as the keystroke that produced it.
422    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            // GPUI itself parses a lone modifier into exactly these names.
466            assert_eq!(Keystroke::parse(key).expect("parses").key, key);
467        }
468        assert!(!is_modifier("p"));
469        assert!(!is_modifier("escape"));
470    }
471}