1use gpui::prelude::*;
29use gpui::{
30 deferred, div, px, App, Context, FocusHandle, IntoElement, KeyDownEvent, SharedString, Window,
31};
32
33use crate::devtools::Probed;
34use crate::input::control_metrics;
35use crate::theme::{theme, ColorName, Size};
36
37type ItemHandler = Box<dyn Fn(&mut Window, &mut App) + 'static>;
38
39enum Entry {
40 Item {
41 label: SharedString,
42 shortcut: Option<SharedString>,
43 danger: bool,
44 disabled: bool,
45 handler: Option<ItemHandler>,
46 },
47 Section(SharedString),
48 Divider,
49}
50
51pub struct MenuColumn {
57 label: SharedString,
58 entries: Vec<Entry>,
59}
60
61impl MenuColumn {
62 pub fn new(label: impl Into<SharedString>) -> Self {
64 MenuColumn {
65 label: label.into(),
66 entries: Vec::new(),
67 }
68 }
69
70 pub fn item(
72 self,
73 label: impl Into<SharedString>,
74 handler: impl Fn(&mut Window, &mut App) + 'static,
75 ) -> Self {
76 self.entry(label, None, false, false, Some(Box::new(handler)))
77 }
78
79 pub fn item_shortcut(
81 self,
82 label: impl Into<SharedString>,
83 shortcut: impl Into<SharedString>,
84 handler: impl Fn(&mut Window, &mut App) + 'static,
85 ) -> Self {
86 self.entry(
87 label,
88 Some(shortcut.into()),
89 false,
90 false,
91 Some(Box::new(handler)),
92 )
93 }
94
95 pub fn danger_item(
97 self,
98 label: impl Into<SharedString>,
99 handler: impl Fn(&mut Window, &mut App) + 'static,
100 ) -> Self {
101 self.entry(label, None, true, false, Some(Box::new(handler)))
102 }
103
104 pub fn disabled_item(self, label: impl Into<SharedString>) -> Self {
107 self.entry(label, None, false, true, None)
108 }
109
110 pub fn section(mut self, label: impl Into<SharedString>) -> Self {
112 self.entries.push(Entry::Section(label.into()));
113 self
114 }
115
116 pub fn divider(mut self) -> Self {
118 self.entries.push(Entry::Divider);
119 self
120 }
121
122 fn entry(
123 mut self,
124 label: impl Into<SharedString>,
125 shortcut: Option<SharedString>,
126 danger: bool,
127 disabled: bool,
128 handler: Option<ItemHandler>,
129 ) -> Self {
130 self.entries.push(Entry::Item {
131 label: label.into(),
132 shortcut,
133 danger,
134 disabled,
135 handler,
136 });
137 self
138 }
139
140 fn actionable(&self) -> Vec<usize> {
142 self.entries
143 .iter()
144 .enumerate()
145 .filter(|(_, e)| {
146 matches!(
147 e,
148 Entry::Item {
149 disabled: false,
150 handler: Some(_),
151 ..
152 }
153 )
154 })
155 .map(|(i, _)| i)
156 .collect()
157 }
158}
159
160pub struct MenuBar {
165 menus: Vec<MenuColumn>,
166 open: Option<usize>,
168 focus: FocusHandle,
169 size: Size,
170 highlight: usize,
172}
173
174impl MenuBar {
175 pub fn new(cx: &mut Context<Self>) -> Self {
176 MenuBar {
177 menus: Vec::new(),
178 open: None,
179 focus: cx.focus_handle(),
180 size: Size::Sm,
181 highlight: 0,
182 }
183 }
184
185 pub fn size(mut self, size: Size) -> Self {
187 self.size = size;
188 self
189 }
190
191 pub fn menu(
193 mut self,
194 label: impl Into<SharedString>,
195 build: impl FnOnce(MenuColumn) -> MenuColumn,
196 ) -> Self {
197 self.menus.push(build(MenuColumn::new(label)));
198 self
199 }
200
201 pub fn push(mut self, menu: MenuColumn) -> Self {
203 self.menus.push(menu);
204 self
205 }
206
207 fn open_menu(&mut self, idx: usize) {
209 self.open = Some(idx);
210 self.highlight = self
211 .menus
212 .get(idx)
213 .and_then(|m| m.actionable().first().copied())
214 .unwrap_or(0);
215 }
216
217 fn move_menu(&mut self, delta: isize) {
218 if self.menus.is_empty() {
219 return;
220 }
221 let cur = self.open.unwrap_or(0) as isize;
222 let len = self.menus.len() as isize;
223 let next = (((cur + delta) % len) + len) % len;
224 self.open_menu(next as usize);
225 }
226
227 fn move_highlight(&mut self, delta: isize) {
228 let Some(open) = self.open else { return };
229 let Some(menu) = self.menus.get(open) else {
230 return;
231 };
232 let items = menu.actionable();
233 if items.is_empty() {
234 return;
235 }
236 let pos = items.iter().position(|&i| i == self.highlight).unwrap_or(0);
237 let len = items.len() as isize;
238 let next = (((pos as isize + delta) % len) + len) % len;
239 self.highlight = items[next as usize];
240 }
241
242 fn activate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
243 let Some(open) = self.open else { return };
244 self.open = None;
245 if let Some(Entry::Item {
246 handler: Some(handler),
247 ..
248 }) = self
249 .menus
250 .get(open)
251 .and_then(|m| m.entries.get(self.highlight))
252 {
253 handler(window, cx);
254 }
255 }
256
257 fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
258 if self.open.is_none() {
259 return;
260 }
261 match event.keystroke.key.as_str() {
262 "escape" => self.open = None,
263 "left" => self.move_menu(-1),
264 "right" => self.move_menu(1),
265 "down" => self.move_highlight(1),
266 "up" => self.move_highlight(-1),
267 "enter" => self.activate(window, cx),
268 _ => return,
269 }
270 cx.notify();
271 cx.stop_propagation();
272 }
273}
274
275impl Render for MenuBar {
276 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
277 let t = theme(cx);
278 let (height, pad_x, font) = control_metrics(self.size);
279 let radius = t.radius(t.default_radius);
280 let surface_color = t.surface().hsla();
281 let surface_hover = t.surface_hover().hsla();
282 let border = t.border().hsla();
283 let text = t.text().hsla();
284 let dimmed = t.dimmed().hsla();
285 let danger = t
286 .color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 6 })
287 .hsla();
288 let font_xs = t.font_size(Size::Xs);
289
290 let mut bar = div()
291 .id("guise-menubar")
292 .track_focus(&self.focus)
293 .flex()
294 .flex_row()
295 .items_center()
296 .gap(px(2.0))
297 .text_size(px(font))
298 .on_key_down(cx.listener(Self::on_key));
299
300 for (mi, menu) in self.menus.iter().enumerate() {
301 let is_open = self.open == Some(mi);
302
303 let mut label = div()
304 .id(("guise-menubar-label", mi))
305 .flex()
306 .items_center()
307 .h(px(height))
308 .px(px(pad_x))
309 .rounded(px(radius))
310 .text_color(text)
311 .hover(move |s| s.bg(surface_hover))
312 .child(menu.label.clone())
313 .on_click(cx.listener(move |this, _ev, window, cx| {
314 if this.open == Some(mi) {
315 this.open = None;
316 } else {
317 this.open_menu(mi);
318 window.focus(&this.focus);
319 }
320 cx.notify();
321 }))
322 .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| {
324 if *hovered && this.open.is_some() && this.open != Some(mi) {
325 this.open_menu(mi);
326 cx.notify();
327 }
328 }));
329 if is_open {
330 label = label.bg(surface_hover);
331 }
332
333 let mut wrap = div().relative().child(label);
334
335 if is_open {
336 let mut dropdown = div()
337 .absolute()
338 .top(px(height + 4.0))
339 .left(px(0.0))
340 .min_w(px(200.0))
341 .flex()
342 .flex_col()
343 .gap(px(2.0))
344 .p(px(4.0))
345 .rounded(px(radius))
346 .border_1()
347 .border_color(border)
348 .bg(surface_color)
349 .shadow_md();
350
351 for (ei, entry) in menu.entries.iter().enumerate() {
352 match entry {
353 Entry::Item {
354 label,
355 shortcut,
356 danger: is_danger,
357 disabled,
358 ..
359 } => {
360 let color = if *disabled {
361 dimmed
362 } else if *is_danger {
363 danger
364 } else {
365 text
366 };
367 let mut item = div()
368 .id(("guise-menubar-item", mi * 1000 + ei))
369 .flex()
370 .items_center()
371 .justify_between()
372 .gap(px(24.0))
373 .px(px(10.0))
374 .py(px(6.0))
375 .rounded(px(4.0))
376 .text_size(px(font))
377 .text_color(color)
378 .child(label.clone())
379 .child(match shortcut {
380 Some(s) => div()
381 .text_size(px(font_xs))
382 .text_color(dimmed)
383 .child(s.clone()),
384 None => div(),
385 });
386 if !*disabled {
387 item = item.hover(move |s| s.bg(surface_hover));
388 if ei == self.highlight {
389 item = item.bg(surface_hover);
390 }
391 item = item.on_click(cx.listener(move |this, _ev, window, cx| {
392 this.open = None;
393 if let Some(Entry::Item {
394 handler: Some(handler),
395 ..
396 }) = this.menus.get(mi).and_then(|m| m.entries.get(ei))
397 {
398 handler(window, cx);
399 }
400 cx.notify();
401 }));
402 }
403 dropdown = dropdown.child(item);
404 }
405 Entry::Section(label) => {
406 dropdown = dropdown.child(
407 div()
408 .px(px(10.0))
409 .pt(px(6.0))
410 .pb(px(2.0))
411 .text_size(px(font_xs))
412 .text_color(dimmed)
413 .child(label.clone()),
414 );
415 }
416 Entry::Divider => {
417 dropdown = dropdown.child(div().my(px(4.0)).h(px(1.0)).bg(border));
418 }
419 }
420 }
421
422 wrap = wrap.child(deferred(dropdown).with_priority(1));
423 }
424
425 bar = bar.child(wrap);
426 }
427
428 bar.probe("MenuBar")
429 }
430}