1use crate::{
2 Selectable, Sizable,
3 actions::{Cancel, SelectLeft, SelectRight},
4 button::{Button, ButtonVariants},
5 h_flex,
6 menu::PopupMenu,
7};
8use gpui::{
9 App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, Focusable,
10 InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
11 Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, anchored,
12 deferred, div, prelude::FluentBuilder, px,
13};
14
15const CONTEXT: &str = "AppMenuBar";
16pub fn init(cx: &mut App) {
17 cx.bind_keys([
18 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
19 KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
20 KeyBinding::new("right", SelectRight, Some(CONTEXT)),
21 ]);
22}
23
24pub struct AppMenuBar {
26 menus: Vec<Entity<AppMenu>>,
27 selected_ix: Option<usize>,
28}
29
30impl AppMenuBar {
31 pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
33 cx.new(|cx| {
34 let menu_bar = cx.entity();
35 let menus = cx
36 .get_menus()
37 .unwrap_or_default()
38 .iter()
39 .enumerate()
40 .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx))
41 .collect();
42
43 Self {
44 selected_ix: None,
45 menus,
46 }
47 })
48 }
49
50 fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
51 let Some(selected_ix) = self.selected_ix else {
52 return;
53 };
54
55 let new_ix = if selected_ix == 0 {
56 self.menus.len().saturating_sub(1)
57 } else {
58 selected_ix.saturating_sub(1)
59 };
60 self.set_selected_index(Some(new_ix), window, cx);
61 }
62
63 fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
64 let Some(selected_ix) = self.selected_ix else {
65 return;
66 };
67
68 let new_ix = if selected_ix + 1 >= self.menus.len() {
69 0
70 } else {
71 selected_ix + 1
72 };
73 self.set_selected_index(Some(new_ix), window, cx);
74 }
75
76 fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
77 self.set_selected_index(None, window, cx);
78 }
79
80 fn set_selected_index(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
81 self.selected_ix = ix;
82 cx.notify();
83 }
84
85 #[inline]
86 fn has_activated_menu(&self) -> bool {
87 self.selected_ix.is_some()
88 }
89}
90
91impl Render for AppMenuBar {
92 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
93 h_flex()
94 .id("app-menu-bar")
95 .key_context(CONTEXT)
96 .on_action(cx.listener(Self::on_move_left))
97 .on_action(cx.listener(Self::on_move_right))
98 .on_action(cx.listener(Self::on_cancel))
99 .size_full()
100 .gap_x_1()
101 .overflow_x_scroll()
102 .children(self.menus.clone())
103 }
104}
105
106pub(super) struct AppMenu {
108 menu_bar: Entity<AppMenuBar>,
109 ix: usize,
110 name: SharedString,
111 menu: OwnedMenu,
112 popup_menu: Option<Entity<PopupMenu>>,
113
114 _subscription: Option<Subscription>,
115}
116
117impl AppMenu {
118 pub(super) fn new(
119 ix: usize,
120 menu: &OwnedMenu,
121 menu_bar: Entity<AppMenuBar>,
122 _: &mut Window,
123 cx: &mut App,
124 ) -> Entity<Self> {
125 let name = menu.name.clone();
126 cx.new(|_| Self {
127 ix,
128 menu_bar,
129 name,
130 menu: menu.clone(),
131 popup_menu: None,
132 _subscription: None,
133 })
134 }
135
136 fn build_popup_menu(
137 &mut self,
138 window: &mut Window,
139 cx: &mut Context<Self>,
140 ) -> Entity<PopupMenu> {
141 let popup_menu = match self.popup_menu.as_ref() {
142 None => {
143 let items = self.menu.items.clone();
144 let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
145 menu.when_some(window.focused(cx), |this, handle| {
146 this.action_context(handle)
147 })
148 .with_menu_items(items, window, cx)
149 });
150 popup_menu.read(cx).focus_handle(cx).focus(window);
151 self._subscription =
152 Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
153 self.popup_menu = Some(popup_menu.clone());
154
155 popup_menu
156 }
157 Some(menu) => menu.clone(),
158 };
159
160 let focus_handle = popup_menu.read(cx).focus_handle(cx);
161 if !focus_handle.contains_focused(window, cx) {
162 focus_handle.focus(window);
163 }
164
165 popup_menu
166 }
167
168 fn handle_dismiss(
169 &mut self,
170 _: &Entity<PopupMenu>,
171 _: &DismissEvent,
172 window: &mut Window,
173 cx: &mut Context<Self>,
174 ) {
175 self._subscription.take();
176 self.popup_menu.take();
177 self.menu_bar.update(cx, |state, cx| {
178 state.on_cancel(&Cancel, window, cx);
179 });
180 }
181
182 fn handle_trigger_click(
183 &mut self,
184 _: &ClickEvent,
185 window: &mut Window,
186 cx: &mut Context<Self>,
187 ) {
188 let is_selected = self.menu_bar.read(cx).selected_ix == Some(self.ix);
189
190 _ = self.menu_bar.update(cx, |state, cx| {
191 let new_ix = if is_selected { None } else { Some(self.ix) };
192 state.set_selected_index(new_ix, window, cx);
193 });
194 }
195
196 fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
197 if !*hovered {
198 return;
199 }
200
201 let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
202 if !has_activated_menu {
203 return;
204 }
205
206 _ = self.menu_bar.update(cx, |state, cx| {
207 state.set_selected_index(Some(self.ix), window, cx);
208 });
209 }
210}
211
212impl Render for AppMenu {
213 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
214 let menu_bar = self.menu_bar.read(cx);
215 let is_selected = menu_bar.selected_ix == Some(self.ix);
216
217 div()
218 .id(self.ix)
219 .relative()
220 .child(
221 Button::new("menu")
222 .small()
223 .py_0p5()
224 .compact()
225 .ghost()
226 .label(self.name.clone())
227 .selected(is_selected)
228 .on_mouse_down(MouseButton::Left, |_, window, cx| {
229 window.prevent_default();
231 cx.stop_propagation();
232 })
233 .on_click(cx.listener(Self::handle_trigger_click)),
234 )
235 .on_hover(cx.listener(Self::handle_hover))
236 .when(is_selected, |this| {
237 this.child(deferred(
238 anchored()
239 .anchor(gpui::Corner::TopLeft)
240 .snap_to_window_with_margin(px(8.))
241 .child(
242 div()
243 .size_full()
244 .occlude()
245 .top_1()
246 .child(self.build_popup_menu(window, cx)),
247 ),
248 ))
249 })
250 }
251}