1use std::{
5 cell::RefCell,
6 collections::{HashMap, HashSet},
7 rc::Rc,
8};
9
10use gpui_kit::{
11 App, AppContext as _, Bounds, Context, ElementId, Entity, EventEmitter, FocusHandle,
12 InteractiveElement as _, IntoElement, MouseButton, OwnedMenu, ParentElement as _, Pixels,
13 RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription,
14 Window,
15 base::{
16 Align, Disableable, ElementExt as _, GlobalState, Selectable as _, TestSupportExt as _,
17 actions::{SelectDown, SelectLeft, SelectRight},
18 h_flex,
19 },
20 div,
21 prelude::FluentBuilder as _,
22};
23
24use super::{BAR_CONTEXT, MenuAnchor, MenuEntry, MenuEvent, MenuState, panel::MenuPanels};
25use crate::{ActiveTheme as _, Button, ButtonSize};
26
27#[derive(Clone)]
29#[non_exhaustive]
30pub struct MenuBarMenu {
31 key: SharedString,
32 label: SharedString,
33 disabled: bool,
34 entries: Vec<MenuEntry>,
35}
36
37impl MenuBarMenu {
38 pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
40 Self {
41 key: key.into(),
42 label: label.into(),
43 disabled: false,
44 entries: Vec::new(),
45 }
46 }
47
48 pub fn entries(mut self, entries: impl IntoIterator<Item = impl Into<MenuEntry>>) -> Self {
50 self.entries.extend(entries.into_iter().map(Into::into));
51 self
52 }
53
54 pub fn key(&self) -> &SharedString {
56 &self.key
57 }
58
59 pub fn label(&self) -> &SharedString {
61 &self.label
62 }
63
64 pub fn is_disabled(&self) -> bool {
66 self.disabled
67 }
68}
69
70impl Disableable for MenuBarMenu {
71 fn disabled(mut self, disabled: bool) -> Self {
72 self.disabled = disabled;
73 self
74 }
75}
76
77impl From<OwnedMenu> for MenuBarMenu {
81 fn from(menu: OwnedMenu) -> Self {
82 let mut used = HashSet::new();
83 let entries: Vec<MenuEntry> = menu
84 .items
85 .into_iter()
86 .filter_map(|item| MenuEntry::from_app_item_keyed(item, &mut used))
87 .collect();
88 Self::new(menu.name.clone(), menu.name)
89 .disabled(menu.disabled)
90 .entries(entries)
91 }
92}
93
94pub struct MenuBarState {
102 menus: Vec<MenuBarMenu>,
103 open: Option<usize>,
105 menu: Entity<MenuState>,
106 context: Option<FocusHandle>,
108 titles: Rc<RefCell<HashMap<SharedString, Bounds<Pixels>>>>,
110 title_focus: Vec<FocusHandle>,
113 highlighted: usize,
115 _subscriptions: Vec<Subscription>,
116}
117
118impl EventEmitter<MenuEvent> for MenuBarState {}
119
120impl MenuBarState {
121 pub fn new(
123 menus: impl IntoIterator<Item = impl Into<MenuBarMenu>>,
124 cx: &mut Context<Self>,
125 ) -> Self {
126 let menu = cx.new(MenuState::new);
127 let this = cx.weak_entity();
128 menu.update(cx, |menu, _| {
129 menu.set_step_handler(Rc::new(move |delta, window, cx| {
130 this.update(cx, |bar, cx| bar.step(delta, window, cx)).ok();
131 }))
132 });
133 let subscriptions = vec![
134 cx.observe(&menu, |_, _, cx| cx.notify()),
135 cx.subscribe(&menu, |this, menu, event: &MenuEvent, cx| {
136 if *event == MenuEvent::Closed && !menu.read(cx).is_open() {
139 this.open = None;
140 this.context = None;
141 }
142 cx.emit(event.clone());
143 cx.notify();
144 }),
145 ];
146 let menus: Vec<MenuBarMenu> = menus.into_iter().map(Into::into).collect();
147 let title_focus = menus.iter().map(|_| cx.focus_handle()).collect();
148 Self {
149 menus,
150 open: None,
151 menu,
152 context: None,
153 titles: Rc::default(),
154 title_focus,
155 highlighted: 0,
156 _subscriptions: subscriptions,
157 }
158 }
159
160 pub fn from_app_menus(cx: &mut Context<Self>) -> Self {
163 let menus = cx.get_menus().unwrap_or_default();
164 Self::new(menus, cx)
165 }
166
167 pub fn menus(&self) -> &[MenuBarMenu] {
169 &self.menus
170 }
171
172 pub fn set_menus(
174 &mut self,
175 menus: impl IntoIterator<Item = impl Into<MenuBarMenu>>,
176 window: &mut Window,
177 cx: &mut Context<Self>,
178 ) {
179 self.close(window, cx);
180 self.menus = menus.into_iter().map(Into::into).collect();
181 self.title_focus = self.menus.iter().map(|_| cx.focus_handle()).collect();
182 self.highlighted = self.highlighted.min(self.menus.len().saturating_sub(1));
183 cx.notify();
184 }
185
186 pub fn open_menu(&self) -> Option<&SharedString> {
188 Some(self.menus.get(self.open?)?.key())
189 }
190
191 pub fn is_open(&self) -> bool {
193 self.open.is_some()
194 }
195
196 pub fn menu(&self) -> &Entity<MenuState> {
198 &self.menu
199 }
200
201 pub fn open(&mut self, key: &str, window: &mut Window, cx: &mut Context<Self>) {
203 if let Some(index) = self.menus.iter().position(|menu| menu.key == key) {
204 self.open_at(index, false, window, cx);
205 }
206 }
207
208 pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
210 self.menu.update(cx, |menu, cx| menu.close(window, cx));
211 }
212
213 fn open_at(
216 &mut self,
217 index: usize,
218 keyboard: bool,
219 window: &mut Window,
220 cx: &mut Context<Self>,
221 ) {
222 let Some(menu) = self.menus.get(index).filter(|menu| !menu.disabled) else {
223 return;
224 };
225 if !self.menu.read(cx).is_open() {
226 self.context = window.focused(cx);
227 }
228 let bounds = self
229 .titles
230 .borrow()
231 .get(&menu.key)
232 .copied()
233 .unwrap_or_default();
234 let anchor = MenuAnchor::Trigger(bounds, Align::Start);
235 let entries = menu.entries.clone();
236 let context = self.context.clone();
237 self.menu.update(cx, |menu, cx| {
238 match &context {
239 Some(context) => menu.open_for(entries, anchor, context, window, cx),
240 None => menu.open(entries, anchor, window, cx),
241 }
242 if keyboard {
243 menu.highlight_edge(false, cx);
244 }
245 });
246 self.open = Some(index);
247 self.highlighted = index;
248 cx.notify();
249 }
250
251 fn move_highlight(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
254 if let Some(index) = self.neighbour(self.highlighted, delta) {
255 self.highlighted = index;
256 self.title_focus[index].focus(window, cx);
257 cx.notify();
258 }
259 }
260
261 fn open_highlighted(&mut self, window: &mut Window, cx: &mut Context<Self>) {
264 self.open_at(self.highlighted, true, window, cx);
265 }
266
267 fn neighbour(&self, from: usize, delta: isize) -> Option<usize> {
270 let count = self.menus.len() as isize;
271 let mut index = from as isize;
272 for _ in 0..count {
273 index = (index + delta).rem_euclid(count);
274 if !self.menus[index as usize].disabled {
275 return Some(index as usize);
276 }
277 }
278 None
279 }
280
281 fn step(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
283 let Some(open) = self.open else {
284 return;
285 };
286 if let Some(index) = self.neighbour(open, delta).filter(|index| *index != open) {
287 self.open_at(index, true, window, cx);
288 }
289 }
290}
291
292#[derive(IntoElement)]
322pub struct MenuBar {
323 id: ElementId,
324 state: Entity<MenuBarState>,
325 plain: bool,
326}
327
328impl MenuBar {
329 pub fn new(id: impl Into<ElementId>, state: &Entity<MenuBarState>) -> Self {
331 Self {
332 id: id.into(),
333 state: state.clone(),
334 plain: false,
335 }
336 }
337
338 pub fn plain(mut self) -> Self {
341 self.plain = true;
342 self
343 }
344}
345
346impl RenderOnce for MenuBar {
347 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
348 let theme = cx.theme();
349 let (frame_fill, frame_border, frame_radius, touch) = (
350 theme.select_trigger,
351 theme.select_trigger_border,
352 ButtonSize::Default.geometry(theme).radius,
353 theme.touch,
354 );
355 let (title_ink, open_fill) = (theme.foreground(), theme.popover_accent);
358 let (menus, open, menu, titles, title_focus, highlighted) = {
359 let state = self.state.read(cx);
360 let open = state.open.filter(|_| state.menu.read(cx).is_open());
361 (
362 state.menus.clone(),
363 open,
364 state.menu.clone(),
365 state.titles.clone(),
366 state.title_focus.clone(),
367 state.highlighted,
368 )
369 };
370 let bar_id = self.id.clone();
371 let on = |f: fn(&mut MenuBarState, &mut Window, &mut Context<MenuBarState>)| {
372 let state = self.state.clone();
373 move |window: &mut Window, cx: &mut App| {
374 state.update(cx, |state, cx| f(state, window, cx))
375 }
376 };
377 let (left, right, down) = (
378 on(|state, window, cx| state.move_highlight(-1, window, cx)),
379 on(|state, window, cx| state.move_highlight(1, window, cx)),
380 on(|state, window, cx| state.open_highlighted(window, cx)),
381 );
382 h_flex()
383 .id(self.id.clone())
384 .test_support()
385 .key_context(BAR_CONTEXT)
386 .role(Role::MenuBar)
387 .when(open.is_none(), |this| {
388 this.on_action(move |_: &SelectLeft, window, cx| left(window, cx))
389 .on_action(move |_: &SelectRight, window, cx| right(window, cx))
390 .on_action(move |_: &SelectDown, window, cx| down(window, cx))
391 })
392 .flex_shrink_0()
393 .items_center()
394 .when(!self.plain, |this| {
395 this.p_1()
396 .rounded(frame_radius)
397 .bg(frame_fill)
398 .border_1()
399 .border_color(frame_border)
400 })
401 .children(menus.into_iter().enumerate().map(|(index, entry)| {
402 let is_open = open == Some(index);
403 let disabled = entry.disabled;
404 let key = entry.key.clone();
405 let titles = titles.clone();
406 let press = self.state.clone();
407 let hover = self.state.clone();
408 let keyboard = self.state.clone();
409 let title = Button::new(ElementId::NamedChild(bar_id.clone().into(), key.clone()))
413 .ghost()
414 .size(ButtonSize::Default)
415 .label(entry.label.clone())
416 .accessibility_label(entry.label)
417 .with_role(Role::MenuItem)
418 .with_expanded(is_open)
419 .open(is_open)
420 .disabled(disabled)
421 .when(!disabled, |this| this.text_color(title_ink))
422 .when(is_open, |this| this.bg(open_fill))
423 .track_focus(&title_focus[index])
424 .tab_stop(index == highlighted)
425 .on_click(move |event, window, cx| {
426 if event.is_keyboard() {
427 keyboard.update(cx, |state, cx| {
428 state.highlighted = index;
429 state.open_at(index, true, window, cx)
430 });
431 }
432 })
433 .when(!disabled, |this| {
434 this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
435 window.prevent_default();
438 cx.stop_propagation();
439 GlobalState::suppress_text_selection(cx);
440 if open == Some(index) {
443 press.update(cx, |state, cx| state.close(window, cx));
444 } else {
445 press.update(cx, |state, cx| {
446 state.open_at(index, false, window, cx)
447 });
448 }
449 })
450 .when(!touch, |this| {
451 this.on_mouse_move(move |_, window, cx| {
452 let switch = {
453 let state = hover.read(cx);
454 state.menu.read(cx).is_open() && state.open != Some(index)
455 };
456 if switch {
457 hover.update(cx, |state, cx| {
458 state.open_at(index, false, window, cx)
459 });
460 }
461 })
462 })
463 });
464 div()
467 .relative()
468 .flex_shrink_0()
469 .child(title)
470 .on_prepaint(move |bounds, _, _| {
471 titles.borrow_mut().insert(key, bounds);
472 })
473 }))
474 .child(MenuPanels::new(
475 ElementId::NamedChild(self.id.into(), "menus".into()),
476 &menu,
477 ))
478 }
479}