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
143 .entries
144 .iter()
145 .enumerate()
146 .filter(|(_, e)| {
147 matches!(
148 e,
149 Entry::Item {
150 disabled: false,
151 handler: Some(_),
152 ..
153 }
154 )
155 })
156 .map(|(i, _)| i)
157 .collect()
158 }
159}
160
161pub struct MenuBar {
166 menus: Vec<MenuColumn>,
167 open: Option<usize>,
169 focus: FocusHandle,
170 size: Size,
171 highlight: usize,
173}
174
175impl MenuBar {
176 pub fn new(cx: &mut Context<Self>) -> Self {
177 MenuBar {
178 menus: Vec::new(),
179 open: None,
180 focus: cx.focus_handle(),
181 size: Size::Sm,
182 highlight: 0,
183 }
184 }
185
186 pub fn size(mut self, size: Size) -> Self {
188 self.size = size;
189 self
190 }
191
192 pub fn menu(
194 mut self,
195 label: impl Into<SharedString>,
196 build: impl FnOnce(MenuColumn) -> MenuColumn,
197 ) -> Self {
198 self.menus.push(build(MenuColumn::new(label)));
199 self
200 }
201
202 pub fn push(mut self, menu: MenuColumn) -> Self {
204 self.menus.push(menu);
205 self
206 }
207
208 fn open_menu(&mut self, idx: usize) {
210 self.open = Some(idx);
211 self.highlight = self
212 .menus
213 .get(idx)
214 .and_then(|m| m.actionable().first().copied())
215 .unwrap_or(0);
216 }
217
218 fn move_menu(&mut self, delta: isize) {
219 if self.menus.is_empty() {
220 return;
221 }
222 let cur = self.open.unwrap_or(0) as isize;
223 let len = self.menus.len() as isize;
224 let next = (((cur + delta) % len) + len) % len;
225 self.open_menu(next as usize);
226 }
227
228 fn move_highlight(&mut self, delta: isize) {
229 let Some(open) = self.open else { return };
230 let Some(menu) = self.menus.get(open) else {
231 return;
232 };
233 let items = menu.actionable();
234 if items.is_empty() {
235 return;
236 }
237 let pos = items.iter().position(|&i| i == self.highlight).unwrap_or(0);
238 let len = items.len() as isize;
239 let next = (((pos as isize + delta) % len) + len) % len;
240 self.highlight = items[next as usize];
241 }
242
243 fn activate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
244 let Some(open) = self.open else { return };
245 self.open = None;
246 if let Some(Entry::Item {
247 handler: Some(handler),
248 ..
249 }) = self
250 .menus
251 .get(open)
252 .and_then(|m| m.entries.get(self.highlight))
253 {
254 handler(window, cx);
255 }
256 }
257
258 fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
259 if self.open.is_none() {
260 return;
261 }
262 match event.keystroke.key.as_str() {
263 "escape" => self.open = None,
264 "left" => self.move_menu(-1),
265 "right" => self.move_menu(1),
266 "down" => self.move_highlight(1),
267 "up" => self.move_highlight(-1),
268 "enter" => self.activate(window, cx),
269 _ => return,
270 }
271 cx.notify();
272 cx.stop_propagation();
273 }
274}
275
276impl Render for MenuBar {
277 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
278 let t = theme(cx);
279 let (height, pad_x, font) = control_metrics(self.size);
280 let radius = t.radius(t.default_radius);
281 let surface_color = t.surface().hsla();
282 let surface_hover = t.surface_hover().hsla();
283 let border = t.border().hsla();
284 let text = t.text().hsla();
285 let dimmed = t.dimmed().hsla();
286 let danger = t
287 .color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 6 })
288 .hsla();
289 let font_xs = t.font_size(Size::Xs);
290
291 let mut bar = div()
292 .id("guise-menubar")
293 .track_focus(&self.focus)
294 .flex()
295 .flex_row()
296 .items_center()
297 .gap(px(2.0))
298 .text_size(px(font))
299 .on_key_down(cx.listener(Self::on_key));
300
301 for (mi, menu) in self.menus.iter().enumerate() {
302 let is_open = self.open == Some(mi);
303
304 let mut label = div()
305 .id(("guise-menubar-label", mi))
306 .flex()
307 .items_center()
308 .h(px(height))
309 .px(px(pad_x))
310 .rounded(px(radius))
311 .text_color(text)
312 .hover(move |s| s.bg(surface_hover))
313 .child(menu.label.clone())
314 .on_click(cx.listener(move |this, _ev, window, cx| {
315 if this.open == Some(mi) {
316 this.open = None;
317 } else {
318 this.open_menu(mi);
319 window.focus(&this.focus);
320 }
321 cx.notify();
322 }))
323 .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| {
325 if *hovered && this.open.is_some() && this.open != Some(mi) {
326 this.open_menu(mi);
327 cx.notify();
328 }
329 }));
330 if is_open {
331 label = label.bg(surface_hover);
332 }
333
334 let mut wrap = div().relative().child(label);
335
336 if is_open {
337 let mut dropdown = div()
338 .absolute()
339 .top(px(height + 4.0))
340 .left(px(0.0))
341 .min_w(px(200.0))
342 .flex()
343 .flex_col()
344 .gap(px(2.0))
345 .p(px(4.0))
346 .rounded(px(radius))
347 .border_1()
348 .border_color(border)
349 .bg(surface_color)
350 .shadow_md();
351
352 for (ei, entry) in menu.entries.iter().enumerate() {
353 match entry {
354 Entry::Item {
355 label,
356 shortcut,
357 danger: is_danger,
358 disabled,
359 ..
360 } => {
361 let color = if *disabled {
362 dimmed
363 } else if *is_danger {
364 danger
365 } else {
366 text
367 };
368 let mut item = div()
369 .id(("guise-menubar-item", mi * 1000 + ei))
370 .flex()
371 .items_center()
372 .justify_between()
373 .gap(px(24.0))
374 .px(px(10.0))
375 .py(px(6.0))
376 .rounded(px(4.0))
377 .text_size(px(font))
378 .text_color(color)
379 .child(label.clone())
380 .child(match shortcut {
381 Some(s) => div()
382 .text_size(px(font_xs))
383 .text_color(dimmed)
384 .child(s.clone()),
385 None => div(),
386 });
387 if !*disabled {
388 item = item.hover(move |s| s.bg(surface_hover));
389 if ei == self.highlight {
390 item = item.bg(surface_hover);
391 }
392 item = item.on_click(cx.listener(move |this, _ev, window, cx| {
393 this.open = None;
394 if let Some(Entry::Item {
395 handler: Some(handler),
396 ..
397 }) = this.menus.get(mi).and_then(|m| m.entries.get(ei))
398 {
399 handler(window, cx);
400 }
401 cx.notify();
402 }));
403 }
404 dropdown = dropdown.child(item);
405 }
406 Entry::Section(label) => {
407 dropdown = dropdown.child(
408 div()
409 .px(px(10.0))
410 .pt(px(6.0))
411 .pb(px(2.0))
412 .text_size(px(font_xs))
413 .text_color(dimmed)
414 .child(label.clone()),
415 );
416 }
417 Entry::Divider => {
418 dropdown = dropdown.child(div().my(px(4.0)).h(px(1.0)).bg(border));
419 }
420 }
421 }
422
423 wrap = wrap.child(deferred(dropdown).with_priority(1));
424 }
425
426 bar = bar.child(wrap);
427 }
428
429 bar.probe("MenuBar")
430 }
431}