keybindings.rs

  1use std::{fmt::Write as _, ops::Range, time::Duration};
  2
  3use db::anyhow::anyhow;
  4use gpui::{
  5    AnyElement, AppContext as _, Axis, Context, Entity, EventEmitter, FocusHandle, Focusable,
  6    FontWeight, Global, IntoElement, Length, ListHorizontalSizingBehavior, ListSizingBehavior,
  7    MouseButton, Subscription, Task, UniformListScrollHandle, actions, div, px, uniform_list,
  8};
  9
 10use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
 11use settings::Settings as _;
 12use ui::{
 13    ActiveTheme as _, App, BorrowAppContext, ParentElement as _, Render, Scrollbar, ScrollbarState,
 14    SharedString, Styled as _, Window, prelude::*,
 15};
 16use workspace::{Item, SerializableItem, Workspace, register_serializable_item};
 17
 18use crate::{
 19    keybindings::persistence::KEYBINDING_EDITORS,
 20    ui_components::table::{Table, TableInteractionState},
 21};
 22
 23actions!(zed, [OpenKeymapEditor]);
 24
 25pub fn init(cx: &mut App) {
 26    let keymap_event_channel = KeymapEventChannel::new();
 27    cx.set_global(keymap_event_channel);
 28
 29    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
 30        workspace.register_action(|workspace, _: &OpenKeymapEditor, window, cx| {
 31            let open_keymap_editor = KeymapEditor::new(window, cx);
 32            workspace.add_item_to_center(Box::new(open_keymap_editor), window, cx);
 33        });
 34    })
 35    .detach();
 36
 37    register_serializable_item::<KeymapEditor>(cx);
 38}
 39
 40pub struct KeymapEventChannel {}
 41
 42impl Global for KeymapEventChannel {}
 43
 44impl KeymapEventChannel {
 45    fn new() -> Self {
 46        Self {}
 47    }
 48
 49    pub fn trigger_keymap_changed(cx: &mut App) {
 50        cx.update_global(|_event_channel: &mut Self, _| {
 51            /* triggers observers in KeymapEditors */
 52        });
 53    }
 54}
 55
 56struct KeymapEditor {
 57    focus_handle: FocusHandle,
 58    _keymap_subscription: Subscription,
 59    processed_bindings: Vec<ProcessedKeybinding>,
 60    table_interaction_state: Entity<TableInteractionState>,
 61    scroll_handle: UniformListScrollHandle,
 62    horizontal_scrollbar: ScrollbarProperties,
 63    vertical_scrollbar: ScrollbarProperties,
 64}
 65
 66impl EventEmitter<()> for KeymapEditor {}
 67
 68impl Focusable for KeymapEditor {
 69    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
 70        self.focus_handle.clone()
 71    }
 72}
 73
 74impl KeymapEditor {
 75    fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
 76        let this = cx.new(|cx| {
 77            let focus_handle = cx.focus_handle();
 78
 79            let _keymap_subscription = cx.observe_global::<KeymapEventChannel>(|this, cx| {
 80                let key_bindings = Self::process_bindings(cx);
 81                this.processed_bindings = key_bindings;
 82            });
 83
 84            let table_interaction_state = TableInteractionState::new(window, cx);
 85
 86            let scroll_handle = UniformListScrollHandle::new();
 87            let vertical_scrollbar = ScrollbarProperties {
 88                axis: Axis::Vertical,
 89                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
 90                show_scrollbar: false,
 91                show_track: false,
 92                auto_hide: false,
 93                hide_task: None,
 94            };
 95
 96            let horizontal_scrollbar = ScrollbarProperties {
 97                axis: Axis::Horizontal,
 98                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
 99                show_scrollbar: false,
100                show_track: false,
101                auto_hide: false,
102                hide_task: None,
103            };
104
105            let mut this = Self {
106                focus_handle: focus_handle.clone(),
107                _keymap_subscription,
108                processed_bindings: vec![],
109                scroll_handle,
110                horizontal_scrollbar,
111                vertical_scrollbar,
112                table_interaction_state,
113            };
114
115            this.update_scrollbar_visibility(cx);
116            this
117        });
118        this
119    }
120
121    fn process_bindings(cx: &mut Context<Self>) -> Vec<ProcessedKeybinding> {
122        let key_bindings_ptr = cx.key_bindings();
123        let lock = key_bindings_ptr.borrow();
124        let key_bindings = lock.bindings();
125
126        let mut processed_bindings = Vec::new();
127
128        for key_binding in key_bindings {
129            let mut keystroke_text = String::new();
130            for keystroke in key_binding.keystrokes() {
131                write!(&mut keystroke_text, "{} ", keystroke.unparse()).ok();
132            }
133            let keystroke_text = keystroke_text.trim().to_string();
134
135            let context = key_binding
136                .predicate()
137                .map(|predicate| predicate.to_string())
138                .unwrap_or_else(|| "<global>".to_string());
139
140            processed_bindings.push(ProcessedKeybinding {
141                keystroke_text: keystroke_text.into(),
142                action: key_binding.action().name().into(),
143                context: context.into(),
144            })
145        }
146        processed_bindings
147    }
148
149    fn update_scrollbar_visibility(&mut self, cx: &mut Context<Self>) {
150        let show_setting = EditorSettings::get_global(cx).scrollbar.show;
151
152        let scroll_handle = self.scroll_handle.0.borrow();
153
154        let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
155            ShowScrollbar::Auto => true,
156            ShowScrollbar::System => cx
157                .try_global::<ScrollbarAutoHide>()
158                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
159            ShowScrollbar::Always => false,
160            ShowScrollbar::Never => false,
161        };
162
163        let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
164            (size.contents.width > size.item.width).then_some(size.contents.width)
165        });
166
167        // is there an item long enough that we should show a horizontal scrollbar?
168        let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
169            longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
170        } else {
171            true
172        };
173
174        let show_scrollbar = match show_setting {
175            ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
176            ShowScrollbar::Never => false,
177        };
178        let show_vertical = show_scrollbar;
179
180        let show_horizontal = item_wider_than_container && show_scrollbar;
181
182        let show_horizontal_track =
183            show_horizontal && matches!(show_setting, ShowScrollbar::Always);
184
185        // TODO: we probably should hide the scroll track when the list doesn't need to scroll
186        let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
187
188        self.vertical_scrollbar = ScrollbarProperties {
189            axis: self.vertical_scrollbar.axis,
190            state: self.vertical_scrollbar.state.clone(),
191            show_scrollbar: show_vertical,
192            show_track: show_vertical_track,
193            auto_hide: autohide(show_setting, cx),
194            hide_task: None,
195        };
196
197        self.horizontal_scrollbar = ScrollbarProperties {
198            axis: self.horizontal_scrollbar.axis,
199            state: self.horizontal_scrollbar.state.clone(),
200            show_scrollbar: show_horizontal,
201            show_track: show_horizontal_track,
202            auto_hide: autohide(show_setting, cx),
203            hide_task: None,
204        };
205
206        cx.notify();
207    }
208
209    fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
210        self.horizontal_scrollbar.hide(window, cx);
211        self.vertical_scrollbar.hide(window, cx);
212    }
213
214    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
215        div()
216            .id("keymap-editor-vertical-scroll")
217            .occlude()
218            .flex_none()
219            .h_full()
220            .cursor_default()
221            .absolute()
222            .right_0()
223            .top_0()
224            .bottom_0()
225            .w(px(12.))
226            .on_mouse_move(cx.listener(|_, _, _, cx| {
227                cx.notify();
228                cx.stop_propagation()
229            }))
230            .on_hover(|_, _, cx| {
231                cx.stop_propagation();
232            })
233            .on_mouse_up(
234                MouseButton::Left,
235                cx.listener(|this, _, window, cx| {
236                    if !this.vertical_scrollbar.state.is_dragging()
237                        && !this.focus_handle.contains_focused(window, cx)
238                    {
239                        this.vertical_scrollbar.hide(window, cx);
240                        cx.notify();
241                    }
242
243                    cx.stop_propagation();
244                }),
245            )
246            .on_any_mouse_down(|_, _, cx| {
247                cx.stop_propagation();
248            })
249            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
250                cx.notify();
251            }))
252            .children(Scrollbar::vertical(self.vertical_scrollbar.state.clone()))
253    }
254
255    /// Renders the horizontal scrollbar.
256    ///
257    /// The right offset is used to determine how far to the right the
258    /// scrollbar should extend to, useful for ensuring it doesn't collide
259    /// with the vertical scrollbar when visible.
260    fn render_horizontal_scrollbar(
261        &self,
262        right_offset: Pixels,
263        cx: &mut Context<Self>,
264    ) -> impl IntoElement {
265        div()
266            .id("keymap-editor-horizontal-scroll")
267            .occlude()
268            .flex_none()
269            .w_full()
270            .cursor_default()
271            .absolute()
272            .bottom_neg_px()
273            .left_0()
274            .right_0()
275            .pr(right_offset)
276            .on_mouse_move(cx.listener(|_, _, _, cx| {
277                cx.notify();
278                cx.stop_propagation()
279            }))
280            .on_hover(|_, _, cx| {
281                cx.stop_propagation();
282            })
283            .on_any_mouse_down(|_, _, cx| {
284                cx.stop_propagation();
285            })
286            .on_mouse_up(
287                MouseButton::Left,
288                cx.listener(|this, _, window, cx| {
289                    if !this.horizontal_scrollbar.state.is_dragging()
290                        && !this.focus_handle.contains_focused(window, cx)
291                    {
292                        this.horizontal_scrollbar.hide(window, cx);
293                        cx.notify();
294                    }
295
296                    cx.stop_propagation();
297                }),
298            )
299            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
300                cx.notify();
301            }))
302            .children(Scrollbar::horizontal(
303                // percentage as f32..end_offset as f32,
304                self.horizontal_scrollbar.state.clone(),
305            ))
306    }
307}
308
309// computed state related to how to render scrollbars
310// one per axis
311// on render we just read this off the keymap editor
312// we update it when
313// - settings change
314// - on focus in, on focus out, on hover, etc.
315#[derive(Debug)]
316struct ScrollbarProperties {
317    axis: Axis,
318    show_scrollbar: bool,
319    show_track: bool,
320    auto_hide: bool,
321    hide_task: Option<Task<()>>,
322    state: ScrollbarState,
323}
324
325impl ScrollbarProperties {
326    // Shows the scrollbar and cancels any pending hide task
327    fn show(&mut self, cx: &mut Context<KeymapEditor>) {
328        if !self.auto_hide {
329            return;
330        }
331        self.show_scrollbar = true;
332        self.hide_task.take();
333        cx.notify();
334    }
335
336    fn hide(&mut self, window: &mut Window, cx: &mut Context<KeymapEditor>) {
337        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
338
339        if !self.auto_hide {
340            return;
341        }
342
343        let axis = self.axis;
344        self.hide_task = Some(cx.spawn_in(window, async move |keymap_editor, cx| {
345            cx.background_executor()
346                .timer(SCROLLBAR_SHOW_INTERVAL)
347                .await;
348
349            if let Some(keymap_editor) = keymap_editor.upgrade() {
350                keymap_editor
351                    .update(cx, |keymap_editor, cx| {
352                        match axis {
353                            Axis::Vertical => {
354                                keymap_editor.vertical_scrollbar.show_scrollbar = false
355                            }
356                            Axis::Horizontal => {
357                                keymap_editor.horizontal_scrollbar.show_scrollbar = false
358                            }
359                        }
360                        cx.notify();
361                    })
362                    .ok();
363            }
364        }));
365    }
366}
367
368struct ProcessedKeybinding {
369    keystroke_text: SharedString,
370    action: SharedString,
371    context: SharedString,
372}
373
374impl Item for KeymapEditor {
375    type Event = ();
376
377    fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString {
378        "Keymap Editor".into()
379    }
380}
381
382impl Render for KeymapEditor {
383    fn render(&mut self, _window: &mut Window, cx: &mut ui::Context<Self>) -> impl ui::IntoElement {
384        if self.processed_bindings.is_empty() {
385            self.processed_bindings = Self::process_bindings(cx);
386        }
387
388        let scroll_track_size = px(16.);
389        let h_scroll_offset = if self.vertical_scrollbar.show_scrollbar {
390            // magic number
391            px(3.)
392        } else {
393            px(0.)
394        };
395
396        let row_count = self.processed_bindings.len();
397
398        let theme = cx.theme();
399
400        div()
401            .size_full()
402            .bg(theme.colors().background)
403            .id("keymap-editor")
404            .track_focus(&self.focus_handle)
405            .on_hover(cx.listener(move |this, hovered, window, cx| {
406                if *hovered {
407                    this.horizontal_scrollbar.show(cx);
408                    this.vertical_scrollbar.show(cx);
409                    cx.notify();
410                } else if !this.focus_handle.contains_focused(window, cx) {
411                    this.hide_scrollbars(window, cx);
412                }
413            }))
414            .child(
415                Table::uniform_list(
416                    "keymap-editor-table",
417                    row_count,
418                    cx.processor(|this, range: Range<usize>, window, cx| {
419                        range
420                            .map(|index| {
421                                let binding = &this.processed_bindings[index];
422                                let row = [
423                                    binding.action.clone(),
424                                    binding.keystroke_text.clone(),
425                                    binding.context.clone(),
426                                    // TODO: Add a source field
427                                    // binding.source.clone(),
428                                ];
429
430                                // fixme: pass through callback as a row_cx param
431                                let striped = false;
432
433                                crate::ui_components::table::render_row(
434                                    index, row, row_count, striped, cx,
435                                )
436                            })
437                            .collect()
438                    }),
439                )
440                .header(["Command", "Keystrokes", "Context"])
441                .interactable(&self.table_interaction_state),
442            )
443        // .child(
444        //     table
445        //         .h_full()
446        //         .v_flex()
447        //         .child(table.render_header(headers, cx))
448        //         .child(
449        //             div()
450        //                 .flex_grow()
451        //                 .w_full()
452        //                 .relative()
453        //                 .overflow_hidden()
454        //                 .child(
455        //                     uniform_list(
456        //                         "keybindings",
457        //                         row_count,
458        //                         cx.processor(move |this, range, _, cx| {}),
459        //                     )
460        //                     .size_full()
461        //                     .flex_grow()
462        //                     .track_scroll(self.scroll_handle.clone())
463        //                     .with_sizing_behavior(ListSizingBehavior::Auto)
464        //                     .with_horizontal_sizing_behavior(
465        //                         ListHorizontalSizingBehavior::Unconstrained,
466        //                     ),
467        //                 )
468        //                 .when(self.vertical_scrollbar.show_track, |this| {
469        //                     this.child(
470        //                         v_flex()
471        //                             .h_full()
472        //                             .flex_none()
473        //                             .w(scroll_track_size)
474        //                             .bg(cx.theme().colors().background)
475        //                             .child(
476        //                                 div()
477        //                                     .size_full()
478        //                                     .flex_1()
479        //                                     .border_l_1()
480        //                                     .border_color(cx.theme().colors().border),
481        //                             ),
482        //                     )
483        //                 })
484        //                 .when(self.vertical_scrollbar.show_scrollbar, |this| {
485        //                     this.child(self.render_vertical_scrollbar(cx))
486        //                 }),
487        //         )
488        //         .when(self.horizontal_scrollbar.show_track, |this| {
489        //             this.child(
490        //                 h_flex()
491        //                     .w_full()
492        //                     .h(scroll_track_size)
493        //                     .flex_none()
494        //                     .relative()
495        //                     .child(
496        //                         div()
497        //                             .w_full()
498        //                             .flex_1()
499        //                             // for some reason the horizontal scrollbar is 1px
500        //                             // taller than the vertical scrollbar??
501        //                             .h(scroll_track_size - px(1.))
502        //                             .bg(cx.theme().colors().background)
503        //                             .border_t_1()
504        //                             .border_color(cx.theme().colors().border),
505        //                     )
506        //                     .when(self.vertical_scrollbar.show_track, |this| {
507        //                         this.child(
508        //                             div()
509        //                                 .flex_none()
510        //                                 // -1px prevents a missing pixel between the two container borders
511        //                                 .w(scroll_track_size - px(1.))
512        //                                 .h_full(),
513        //                         )
514        //                         .child(
515        //                             // HACK: Fill the missing 1px 🥲
516        //                             div()
517        //                                 .absolute()
518        //                                 .right(scroll_track_size - px(1.))
519        //                                 .bottom(scroll_track_size - px(1.))
520        //                                 .size_px()
521        //                                 .bg(cx.theme().colors().border),
522        //                         )
523        //                     }),
524        //             )
525        //         })
526        //         .when(self.horizontal_scrollbar.show_scrollbar, |this| {
527        //             this.child(self.render_horizontal_scrollbar(h_scroll_offset, cx))
528        //         }),
529        // )
530    }
531}
532
533impl SerializableItem for KeymapEditor {
534    fn serialized_item_kind() -> &'static str {
535        "KeymapEditor"
536    }
537
538    fn cleanup(
539        workspace_id: workspace::WorkspaceId,
540        alive_items: Vec<workspace::ItemId>,
541        _window: &mut Window,
542        cx: &mut App,
543    ) -> gpui::Task<gpui::Result<()>> {
544        workspace::delete_unloaded_items(
545            alive_items,
546            workspace_id,
547            "keybinding_editors",
548            &KEYBINDING_EDITORS,
549            cx,
550        )
551    }
552
553    fn deserialize(
554        _project: gpui::Entity<project::Project>,
555        _workspace: gpui::WeakEntity<Workspace>,
556        workspace_id: workspace::WorkspaceId,
557        item_id: workspace::ItemId,
558        window: &mut Window,
559        cx: &mut App,
560    ) -> gpui::Task<gpui::Result<gpui::Entity<Self>>> {
561        window.spawn(cx, async move |cx| {
562            if KEYBINDING_EDITORS
563                .get_keybinding_editor(item_id, workspace_id)?
564                .is_some()
565            {
566                cx.update(|window, cx| KeymapEditor::new(window, cx))
567            } else {
568                Err(anyhow!("No keybinding editor to deserialize"))
569            }
570        })
571    }
572
573    fn serialize(
574        &mut self,
575        workspace: &mut Workspace,
576        item_id: workspace::ItemId,
577        _closing: bool,
578        _window: &mut Window,
579        cx: &mut ui::Context<Self>,
580    ) -> Option<gpui::Task<gpui::Result<()>>> {
581        let Some(workspace_id) = workspace.database_id() else {
582            return None;
583        };
584        Some(cx.background_spawn(async move {
585            KEYBINDING_EDITORS
586                .save_keybinding_editor(item_id, workspace_id)
587                .await
588        }))
589    }
590
591    fn should_serialize(&self, _event: &Self::Event) -> bool {
592        false
593    }
594}
595
596mod persistence {
597    use db::{define_connection, query, sqlez_macros::sql};
598    use workspace::WorkspaceDb;
599
600    define_connection! {
601        pub static ref KEYBINDING_EDITORS: KeybindingEditorDb<WorkspaceDb> =
602            &[sql!(
603                CREATE TABLE keybinding_editors (
604                    workspace_id INTEGER,
605                    item_id INTEGER UNIQUE,
606
607                    PRIMARY KEY(workspace_id, item_id),
608                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
609                    ON DELETE CASCADE
610                ) STRICT;
611            )];
612    }
613
614    impl KeybindingEditorDb {
615        query! {
616            pub async fn save_keybinding_editor(
617                item_id: workspace::ItemId,
618                workspace_id: workspace::WorkspaceId
619            ) -> Result<()> {
620                INSERT OR REPLACE INTO keybinding_editors(item_id, workspace_id)
621                VALUES (?, ?)
622            }
623        }
624
625        query! {
626            pub fn get_keybinding_editor(
627                item_id: workspace::ItemId,
628                workspace_id: workspace::WorkspaceId
629            ) -> Result<Option<workspace::ItemId>> {
630                SELECT item_id
631                FROM keybinding_editors
632                WHERE item_id = ? AND workspace_id = ?
633            }
634        }
635    }
636}