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}