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