which_key_modal.rs

  1//! Modal implementation for the which-key display.
  2
  3use gpui::prelude::FluentBuilder;
  4use gpui::{
  5    App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, FontWeight, Keystroke,
  6    ScrollHandle, Subscription, WeakEntity, Window,
  7};
  8use settings::Settings;
  9use std::collections::HashMap;
 10use theme::ThemeSettings;
 11use ui::{
 12    Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*,
 13    text_for_keystrokes,
 14};
 15use workspace::{ModalView, Workspace};
 16
 17use crate::FILTERED_KEYSTROKES;
 18
 19pub struct WhichKeyModal {
 20    _workspace: WeakEntity<Workspace>,
 21    focus_handle: FocusHandle,
 22    scroll_handle: ScrollHandle,
 23    bindings: Vec<(SharedString, SharedString)>,
 24    pending_keys: SharedString,
 25    _pending_input_subscription: Subscription,
 26    _focus_out_subscription: Subscription,
 27}
 28
 29impl WhichKeyModal {
 30    pub fn new(
 31        workspace: WeakEntity<Workspace>,
 32        window: &mut Window,
 33        cx: &mut Context<Self>,
 34    ) -> Self {
 35        // Keep focus where it currently is
 36        let focus_handle = window.focused(cx).unwrap_or(cx.focus_handle());
 37
 38        let handle = cx.weak_entity();
 39        let mut this = Self {
 40            _workspace: workspace,
 41            focus_handle: focus_handle.clone(),
 42            scroll_handle: ScrollHandle::new(),
 43            bindings: Vec::new(),
 44            pending_keys: SharedString::new_static(""),
 45            _pending_input_subscription: cx.observe_pending_input(
 46                window,
 47                |this: &mut Self, window, cx| {
 48                    this.update_pending_keys(window, cx);
 49                },
 50            ),
 51            _focus_out_subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| {
 52                handle.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
 53            }),
 54        };
 55        this.update_pending_keys(window, cx);
 56        this
 57    }
 58
 59    pub fn dismiss(&self, cx: &mut Context<Self>) {
 60        cx.emit(DismissEvent)
 61    }
 62
 63    fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 64        let Some(pending_keys) = window.pending_input_keystrokes() else {
 65            cx.emit(DismissEvent);
 66            return;
 67        };
 68        let bindings = window.possible_bindings_for_input(pending_keys);
 69
 70        let mut binding_data = bindings
 71            .iter()
 72            .map(|binding| {
 73                // Map to keystrokes
 74                (
 75                    binding
 76                        .keystrokes()
 77                        .iter()
 78                        .map(|k| k.inner().to_owned())
 79                        .collect::<Vec<_>>(),
 80                    binding.action(),
 81                )
 82            })
 83            .filter(|(keystrokes, _action)| {
 84                // Check if this binding matches any filtered keystroke pattern
 85                !FILTERED_KEYSTROKES.iter().any(|filtered| {
 86                    keystrokes.len() >= filtered.len()
 87                        && keystrokes[..filtered.len()] == filtered[..]
 88                })
 89            })
 90            .map(|(keystrokes, action)| {
 91                // Map to remaining keystrokes and action name
 92                let remaining_keystrokes = keystrokes[pending_keys.len()..].to_vec();
 93                let action_name: SharedString =
 94                    command_palette::humanize_action_name(action.name()).into();
 95                (remaining_keystrokes, action_name)
 96            })
 97            .collect();
 98
 99        binding_data = group_bindings(binding_data);
100
101        // Sort bindings from shortest to longest, with groups last
102        // Using stable sort to preserve relative order of equal elements
103        binding_data.sort_by(|(keystrokes_a, action_a), (keystrokes_b, action_b)| {
104            // Groups (actions starting with "+") should go last
105            let is_group_a = action_a.starts_with('+');
106            let is_group_b = action_b.starts_with('+');
107
108            // First, separate groups from non-groups
109            let group_cmp = is_group_a.cmp(&is_group_b);
110            if group_cmp != std::cmp::Ordering::Equal {
111                return group_cmp;
112            }
113
114            // Then sort by keystroke count
115            let keystroke_cmp = keystrokes_a.len().cmp(&keystrokes_b.len());
116            if keystroke_cmp != std::cmp::Ordering::Equal {
117                return keystroke_cmp;
118            }
119
120            // Finally sort by text length, then lexicographically for full stability
121            let text_a = text_for_keystrokes(keystrokes_a, cx);
122            let text_b = text_for_keystrokes(keystrokes_b, cx);
123            let text_len_cmp = text_a.len().cmp(&text_b.len());
124            if text_len_cmp != std::cmp::Ordering::Equal {
125                return text_len_cmp;
126            }
127            text_a.cmp(&text_b)
128        });
129        binding_data.dedup();
130        self.pending_keys = text_for_keystrokes(&pending_keys, cx).into();
131        self.bindings = binding_data
132            .into_iter()
133            .map(|(keystrokes, action)| (text_for_keystrokes(&keystrokes, cx).into(), action))
134            .collect();
135    }
136}
137
138impl Render for WhichKeyModal {
139    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
140        let has_rows = !self.bindings.is_empty();
141        let viewport_size = window.viewport_size();
142
143        let max_panel_width = px((f32::from(viewport_size.width) * 0.5).min(480.0));
144        let max_content_height = px(f32::from(viewport_size.height) * 0.4);
145
146        // Push above status bar when visible
147        let status_height = self
148            ._workspace
149            .upgrade()
150            .and_then(|workspace| {
151                workspace.read_with(cx, |workspace, cx| {
152                    if workspace.status_bar_visible(cx) {
153                        Some(
154                            DynamicSpacing::Base04.px(cx) * 2.0
155                                + ThemeSettings::get_global(cx).ui_font_size(cx),
156                        )
157                    } else {
158                        None
159                    }
160                })
161            })
162            .unwrap_or(px(0.));
163
164        let margin_bottom = px(16.);
165        let bottom_offset = margin_bottom + status_height;
166
167        // Title section
168        let title_section = {
169            let mut column = v_flex().gap(px(0.)).child(
170                div()
171                    .child(
172                        Label::new(self.pending_keys.clone())
173                            .size(LabelSize::Default)
174                            .weight(FontWeight::MEDIUM)
175                            .color(Color::Accent),
176                    )
177                    .mb(px(2.)),
178            );
179
180            if has_rows {
181                column = column.child(
182                    div()
183                        .child(Divider::horizontal().color(DividerColor::BorderFaded))
184                        .mb(px(2.)),
185                );
186            }
187
188            column
189        };
190
191        let content = h_flex()
192            .items_start()
193            .id("which-key-content")
194            .gap(px(8.))
195            .overflow_y_scroll()
196            .track_scroll(&self.scroll_handle)
197            .h_full()
198            .max_h(max_content_height)
199            .child(
200                // Keystrokes column
201                v_flex()
202                    .gap(px(4.))
203                    .flex_shrink_0()
204                    .children(self.bindings.iter().map(|(keystrokes, _)| {
205                        div()
206                            .child(
207                                Label::new(keystrokes.clone())
208                                    .size(LabelSize::Default)
209                                    .color(Color::Accent),
210                            )
211                            .text_align(gpui::TextAlign::Right)
212                    })),
213            )
214            .child(
215                // Actions column
216                v_flex()
217                    .gap(px(4.))
218                    .flex_1()
219                    .min_w_0()
220                    .children(self.bindings.iter().map(|(_, action_name)| {
221                        let is_group = action_name.starts_with('+');
222                        let label_color = if is_group {
223                            Color::Success
224                        } else {
225                            Color::Default
226                        };
227
228                        div().child(
229                            Label::new(action_name.clone())
230                                .size(LabelSize::Default)
231                                .color(label_color)
232                                .single_line()
233                                .truncate(),
234                        )
235                    })),
236            );
237
238        div()
239            .id("which-key-buffer-panel-scroll")
240            .occlude()
241            .absolute()
242            .bottom(bottom_offset)
243            .right(px(16.))
244            .min_w(px(220.))
245            .max_w(max_panel_width)
246            .elevation_3(cx)
247            .px(px(12.))
248            .child(v_flex().child(title_section).when(has_rows, |el| {
249                el.child(
250                    div()
251                        .max_h(max_content_height)
252                        .child(content)
253                        .vertical_scrollbar_for(&self.scroll_handle, window, cx),
254                )
255            }))
256    }
257}
258
259impl EventEmitter<DismissEvent> for WhichKeyModal {}
260
261impl Focusable for WhichKeyModal {
262    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
263        self.focus_handle.clone()
264    }
265}
266
267impl ModalView for WhichKeyModal {
268    fn render_bare(&self) -> bool {
269        true
270    }
271}
272
273fn group_bindings(
274    binding_data: Vec<(Vec<Keystroke>, SharedString)>,
275) -> Vec<(Vec<Keystroke>, SharedString)> {
276    let mut groups: HashMap<Option<Keystroke>, Vec<(Vec<Keystroke>, SharedString)>> =
277        HashMap::new();
278
279    // Group bindings by their first keystroke
280    for (remaining_keystrokes, action_name) in binding_data {
281        let first_key = remaining_keystrokes.first().cloned();
282        groups
283            .entry(first_key)
284            .or_default()
285            .push((remaining_keystrokes, action_name));
286    }
287
288    let mut result = Vec::new();
289
290    for (first_key, mut group_bindings) in groups {
291        // Remove duplicates within each group
292        group_bindings.dedup_by_key(|(keystrokes, _)| keystrokes.clone());
293
294        if let Some(first_key) = first_key
295            && group_bindings.len() > 1
296        {
297            // This is a group - create a single entry with just the first keystroke
298            let first_keystroke = vec![first_key];
299            let count = group_bindings.len();
300            result.push((first_keystroke, format!("+{} keybinds", count).into()));
301        } else {
302            // Not a group or empty keystrokes - add all bindings as-is
303            result.append(&mut group_bindings);
304        }
305    }
306
307    result
308}