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