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