1use crate::{
2 Selectable, Sizable,
3 actions::{Cancel, SelectLeft, SelectRight},
4 button::{Button, ButtonVariants},
5 global_state::GlobalState,
6 h_flex,
7 menu::PopupMenu,
8};
9use rgpui::{
10 App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, FocusHandle, Focusable,
11 InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
12 Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, anchored,
13 deferred, div, prelude::FluentBuilder, px,
14};
15
16const CONTEXT: &str = "AppMenuBar";
17pub fn init(cx: &mut App) {
18 cx.bind_keys([
19 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
20 KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
21 KeyBinding::new("right", SelectRight, Some(CONTEXT)),
22 ]);
23}
24
25pub struct AppMenuBar {
27 menus: Vec<Entity<AppMenu>>,
28 selected_index: Option<usize>,
29 action_context: Option<FocusHandle>,
30}
31
32impl AppMenuBar {
33 pub fn new(cx: &mut App) -> Entity<Self> {
35 cx.new(|cx| {
36 let mut this = Self {
37 selected_index: None,
38 action_context: None,
39 menus: Vec::new(),
40 };
41 this.reload(cx);
42 this
43 })
44 }
45
46 pub fn reload(&mut self, cx: &mut Context<Self>) {
48 let menu_bar = cx.entity();
49 let menus: Vec<OwnedMenu> = GlobalState::global(cx)
50 .app_menus()
51 .iter()
52 .cloned()
53 .collect();
54 self.menus = menus
55 .iter()
56 .enumerate()
57 .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), cx))
58 .collect();
59 self.selected_index = None;
60 self.action_context = None;
61 cx.notify();
62 }
63
64 fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
65 let Some(selected_index) = self.selected_index else {
66 return;
67 };
68
69 let new_ix = if selected_index == 0 {
70 self.menus.len().saturating_sub(1)
71 } else {
72 selected_index.saturating_sub(1)
73 };
74 self.set_selected_index(Some(new_ix), window, cx);
75 }
76
77 fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
78 let Some(selected_index) = self.selected_index else {
79 return;
80 };
81
82 let new_ix = if selected_index + 1 >= self.menus.len() {
83 0
84 } else {
85 selected_index + 1
86 };
87 self.set_selected_index(Some(new_ix), window, cx);
88 }
89
90 fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
91 self.set_selected_index(None, window, cx);
92 }
93
94 fn set_selected_index(
95 &mut self,
96 ix: Option<usize>,
97 window: &mut Window,
98 cx: &mut Context<Self>,
99 ) {
100 if self.selected_index.is_none() && ix.is_some() {
101 self.action_context = window.focused(cx);
102 } else if ix.is_none() {
103 if let Some(action_context) = self.action_context.as_ref() {
104 action_context.focus(window, cx);
105 }
106 self.action_context = None;
107 }
108
109 self.selected_index = ix;
110 cx.notify();
111 }
112
113 #[inline]
114 fn has_activated_menu(&self) -> bool {
115 self.selected_index.is_some()
116 }
117}
118
119impl Render for AppMenuBar {
120 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
121 h_flex()
122 .id("app-menu-bar")
123 .key_context(CONTEXT)
124 .on_action(cx.listener(Self::on_move_left))
125 .on_action(cx.listener(Self::on_move_right))
126 .on_action(cx.listener(Self::on_cancel))
127 .size_full()
128 .gap_x_1()
129 .overflow_x_scroll()
130 .children(self.menus.clone())
131 }
132}
133
134pub(super) struct AppMenu {
136 menu_bar: Entity<AppMenuBar>,
137 ix: usize,
138 name: SharedString,
139 menu: OwnedMenu,
140 popup_menu: Option<Entity<PopupMenu>>,
141
142 _subscription: Option<Subscription>,
143}
144
145impl AppMenu {
146 pub(super) fn new(
147 ix: usize,
148 menu: &OwnedMenu,
149 menu_bar: Entity<AppMenuBar>,
150 cx: &mut App,
151 ) -> Entity<Self> {
152 let name = menu.name.clone();
153 cx.new(|_| Self {
154 ix,
155 menu_bar,
156 name,
157 menu: menu.clone(),
158 popup_menu: None,
159 _subscription: None,
160 })
161 }
162
163 fn build_popup_menu(
164 &mut self,
165 window: &mut Window,
166 cx: &mut Context<Self>,
167 ) -> Entity<PopupMenu> {
168 let action_context = self.menu_bar.read(cx).action_context.clone();
169 let popup_menu = match self.popup_menu.as_ref() {
170 None => {
171 let items = self.menu.items.clone();
172 let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
173 menu.with_menu_items(items, window, cx)
174 });
175 popup_menu.update(cx, |menu, cx| {
176 menu.set_action_context(action_context.clone(), cx);
177 });
178 self._subscription =
179 Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
180 self.popup_menu = Some(popup_menu.clone());
181
182 popup_menu
183 }
184 Some(menu) => {
185 menu.update(cx, |menu, cx| {
186 menu.set_action_context(action_context.clone(), cx);
187 });
188 menu.clone()
189 }
190 };
191
192 let focus_handle = popup_menu.read(cx).focus_handle(cx);
193 if !focus_handle.contains_focused(window, cx) {
194 focus_handle.focus(window, cx);
195 }
196
197 popup_menu
198 }
199
200 fn handle_dismiss(
201 &mut self,
202 _: &Entity<PopupMenu>,
203 _: &DismissEvent,
204 window: &mut Window,
205 cx: &mut Context<Self>,
206 ) {
207 self._subscription.take();
208 self.popup_menu.take();
209 self.menu_bar.update(cx, |state, cx| {
210 state.on_cancel(&Cancel, window, cx);
211 });
212 }
213
214 fn handle_trigger_click(
215 &mut self,
216 _: &ClickEvent,
217 window: &mut Window,
218 cx: &mut Context<Self>,
219 ) {
220 let is_selected = self.menu_bar.read(cx).selected_index == Some(self.ix);
221
222 _ = self.menu_bar.update(cx, |state, cx| {
223 let new_ix = if is_selected { None } else { Some(self.ix) };
224 state.set_selected_index(new_ix, window, cx);
225 });
226 }
227
228 fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
229 if !*hovered {
230 return;
231 }
232
233 let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
234 if !has_activated_menu {
235 return;
236 }
237
238 _ = self.menu_bar.update(cx, |state, cx| {
239 state.set_selected_index(Some(self.ix), window, cx);
240 });
241 }
242}
243
244impl Render for AppMenu {
245 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
246 let menu_bar = self.menu_bar.read(cx);
247 let is_selected = menu_bar.selected_index == Some(self.ix);
248
249 div()
250 .id(self.ix)
251 .relative()
252 .child(
253 Button::new("menu")
254 .small()
255 .py_0p5()
256 .compact()
257 .ghost()
258 .label(self.name.clone())
259 .selected(is_selected)
260 .on_mouse_down(MouseButton::Left, |_, window, cx| {
261 window.prevent_default();
263 cx.stop_propagation();
264 })
265 .on_click(cx.listener(Self::handle_trigger_click)),
266 )
267 .on_hover(cx.listener(Self::handle_hover))
268 .when(is_selected, |this| {
269 this.child(deferred(
270 anchored()
271 .anchor(rgpui::Anchor::TopLeft)
272 .snap_to_window_with_margin(px(8.))
273 .child(
274 div()
275 .size_full()
276 .occlude()
277 .top_1()
278 .child(self.build_popup_menu(window, cx)),
279 ),
280 ))
281 })
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 use rgpui::TestAppContext;
290
291 struct TestRoot {
292 menu_bar: Entity<AppMenuBar>,
293 first_focus: FocusHandle,
294 second_focus: FocusHandle,
295 }
296
297 impl Render for TestRoot {
298 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
299 div()
300 .child(div().id("first").track_focus(&self.first_focus))
301 .child(div().id("second").track_focus(&self.second_focus))
302 .child(self.menu_bar.clone())
303 }
304 }
305
306 #[rgpui::test]
307 fn preserves_action_context_while_switching_menus(cx: &mut TestAppContext) {
308 let (root, cx) = cx.add_window_view(|window, cx| {
309 let first_focus = cx.focus_handle();
310 let second_focus = cx.focus_handle();
311 first_focus.focus(window, cx);
312
313 TestRoot {
314 menu_bar: cx.new(|_| AppMenuBar {
315 menus: Vec::new(),
316 selected_index: None,
317 action_context: None,
318 }),
319 first_focus,
320 second_focus,
321 }
322 });
323
324 let (menu_bar, first_focus, second_focus) = root.read_with(cx, |root, _| {
325 (
326 root.menu_bar.clone(),
327 root.first_focus.clone(),
328 root.second_focus.clone(),
329 )
330 });
331
332 menu_bar.update_in(cx, |menu_bar, window, cx| {
333 menu_bar.set_selected_index(Some(0), window, cx);
334 assert_eq!(menu_bar.action_context.as_ref(), Some(&first_focus));
335
336 second_focus.focus(window, cx);
337 menu_bar.set_selected_index(Some(1), window, cx);
338 assert_eq!(menu_bar.action_context.as_ref(), Some(&first_focus));
339
340 menu_bar.set_selected_index(None, window, cx);
341 assert!(menu_bar.action_context.is_none());
342 assert_eq!(window.focused(cx).as_ref(), Some(&first_focus));
343
344 second_focus.focus(window, cx);
345 menu_bar.set_selected_index(Some(0), window, cx);
346 assert_eq!(menu_bar.action_context.as_ref(), Some(&second_focus));
347 });
348 }
349}