1use std::cell::Cell;
40use std::rc::Rc;
41use std::sync::Arc;
42
43use gpui::{
44 div, prelude::FluentBuilder as _, px, App, Bounds, InteractiveElement, IntoElement,
45 MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
46 Styled, Window,
47};
48use herogpui_core::{element_id, Placement};
49use herogpui_theme::ActiveTheme;
50
51use crate::a11y::{self, A11y as _};
52use crate::{Menu, MenuItem};
53
54type ActionCallback = Arc<dyn Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static>;
55type OpenCallback = Arc<dyn Fn(&Option<SharedString>, &mut Window, &mut App) + 'static>;
56
57#[must_use = "builder methods return a new value; pass it on to its component"]
59pub struct MenuBarMenu {
60 key: SharedString,
61 label: SharedString,
62 items: Vec<MenuItem>,
63 disabled_keys: Vec<SharedString>,
64 is_disabled: bool,
65}
66
67impl MenuBarMenu {
68 pub fn new(
71 key: impl Into<SharedString>,
72 label: impl Into<SharedString>,
73 items: Vec<MenuItem>,
74 ) -> Self {
75 Self {
76 key: key.into(),
77 label: label.into(),
78 items,
79 disabled_keys: Vec::new(),
80 is_disabled: false,
81 }
82 }
83
84 pub fn disabled_keys(
86 mut self,
87 keys: impl IntoIterator<Item = impl Into<SharedString>>,
88 ) -> Self {
89 self.disabled_keys = keys.into_iter().map(Into::into).collect();
90 self
91 }
92
93 pub fn is_disabled(mut self, v: bool) -> Self {
96 self.is_disabled = v;
97 self
98 }
99}
100
101#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
103#[derive(IntoElement)]
104pub struct MenuBar {
105 id: gpui::ElementId,
106 menus: Vec<MenuBarMenu>,
107 on_action: Option<ActionCallback>,
108 on_open_change: Option<OpenCallback>,
109}
110
111impl MenuBar {
112 pub fn new(id: impl Into<gpui::ElementId>, menus: Vec<MenuBarMenu>) -> Self {
115 Self {
116 id: id.into(),
117 menus,
118 on_action: None,
119 on_open_change: None,
120 }
121 }
122
123 pub fn on_action(
126 mut self,
127 f: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static,
128 ) -> Self {
129 self.on_action = Some(Arc::new(f));
130 self
131 }
132
133 pub fn on_open_change(
136 mut self,
137 f: impl Fn(&Option<SharedString>, &mut Window, &mut App) + 'static,
138 ) -> Self {
139 self.on_open_change = Some(Arc::new(f));
140 self
141 }
142}
143
144#[derive(Clone)]
146struct BarState {
147 open: gpui::Entity<Option<usize>>,
148 active: gpui::Entity<usize>,
149 focus_first: gpui::Entity<bool>,
150 handles: Rc<Vec<gpui::FocusHandle>>,
151 keys: Rc<Vec<SharedString>>,
152 enabled: Rc<Vec<bool>>,
153 on_open_change: Option<OpenCallback>,
154}
155
156impl BarState {
157 fn set_open(&self, ix: Option<usize>, keyboard: bool, window: &mut Window, cx: &mut App) {
159 if *self.open.read(cx) == ix {
160 return;
161 }
162 self.focus_first.update(cx, |v, _| *v = keyboard);
163 self.open.update(cx, |v, cx| {
164 *v = ix;
165 cx.notify();
166 });
167 if let Some(ix) = ix {
168 self.set_active(ix, cx);
169 }
170 if let Some(cb) = &self.on_open_change {
171 cb(&ix.map(|ix| self.keys[ix].clone()), window, cx);
172 }
173 }
174
175 fn set_active(&self, ix: usize, cx: &mut App) {
176 self.active.update(cx, |v, cx| {
177 if *v != ix {
178 *v = ix;
179 cx.notify();
180 }
181 });
182 }
183
184 fn step(&self, from: usize, forward: bool) -> Option<usize> {
186 let n = self.enabled.len();
187 (1..=n)
188 .map(|d| {
189 if forward {
190 (from + d) % n
191 } else {
192 (from + n - d % n) % n
193 }
194 })
195 .find(|&ix| self.enabled[ix])
196 }
197
198 fn focus_trigger(&self, ix: usize, window: &mut Window, cx: &mut App) {
199 self.set_active(ix, cx);
200 window.focus(&self.handles[ix], cx);
201 }
202}
203
204impl RenderOnce for MenuBar {
205 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
206 let base = self.id.clone();
207 let count = self.menus.len();
208 let open =
209 window.use_keyed_state(element_id::scoped(&base, "open"), cx, |_, _| None::<usize>);
210 let active = window.use_keyed_state(element_id::scoped(&base, "active"), cx, |_, _| 0);
211 let focus_first =
212 window.use_keyed_state(element_id::scoped(&base, "focus-first"), cx, |_, _| false);
213 let mut handles = Vec::with_capacity(count);
214 let mut trigger_bounds = Vec::with_capacity(count);
215 for ix in 0..count {
216 let part = format!("trigger-{ix}");
217 handles.push(
218 window
219 .use_keyed_state(
220 element_id::scoped(&base, format!("{part}-focus")),
221 cx,
222 |_, cx| cx.focus_handle(),
223 )
224 .read(cx)
225 .clone(),
226 );
227 trigger_bounds.push(
228 window
229 .use_keyed_state(
230 element_id::scoped(&base, format!("{part}-bounds")),
231 cx,
232 |_, _| Rc::new(Cell::new(None::<Bounds<Pixels>>)),
233 )
234 .read(cx)
235 .clone(),
236 );
237 }
238 let enabled: Vec<bool> = self.menus.iter().map(|m| !m.is_disabled).collect();
239 let stored_open = *open.read(cx);
243 let open_ix = stored_open.filter(|&ix| enabled.get(ix).copied().unwrap_or(false));
244 if stored_open.is_some() && open_ix.is_none() {
245 open.update(cx, |v, _| *v = None);
246 if let Some(cb) = self.on_open_change.clone() {
247 window.defer(cx, move |window, cx| cb(&None, window, cx));
248 }
249 }
250 let active_ix = {
251 let at = *active.read(cx);
252 if enabled.get(at).copied().unwrap_or(false) {
253 at
254 } else {
255 enabled.iter().position(|e| *e).unwrap_or(0)
256 }
257 };
258 let (phase, overlay_token) = crate::util::overlay_scope(
259 window,
260 cx,
261 element_id::scoped(&base, "phase"),
262 open_ix.is_some(),
263 true,
264 );
265 let shown =
267 window.use_keyed_state(element_id::scoped(&base, "shown"), cx, |_, _| None::<usize>);
268 if open_ix.is_some() && *shown.read(cx) != open_ix {
269 shown.update(cx, |v, _| *v = open_ix);
270 }
271 let shown_ix = open_ix.or(*shown.read(cx));
272
273 let state = BarState {
274 open,
275 active,
276 focus_first: focus_first.clone(),
277 handles: Rc::new(handles),
278 keys: Rc::new(self.menus.iter().map(|m| m.key.clone()).collect()),
279 enabled: Rc::new(enabled),
280 on_open_change: self.on_open_change.clone(),
281 };
282
283 let colors = cx.colors().clone();
284 let radius = crate::util::control_radius(cx);
285 let focus_visible = crate::util::focus_visible(cx);
286 let cursor = crate::util::interactive_cursor(cx);
287
288 let mut bar = div()
289 .id(element_id::scoped(&base, "bar"))
290 .a11y(a11y::Role::MenuBar)
291 .flex()
292 .flex_row()
293 .items_center()
294 .gap(px(2.))
295 .p(px(4.))
296 .rounded(radius + px(4.))
297 .bg(colors.surface.background)
298 .text_sm();
299
300 for (ix, menu) in self.menus.iter().enumerate() {
301 let handle = state.handles[ix].clone().tab_stop(ix == active_ix);
302 let is_open = open_ix == Some(ix);
303 let disabled = menu.is_disabled;
304 let focused = handle.is_focused(window);
305 let bounds_probe = trigger_bounds[ix].clone();
306 let mut trigger = div()
307 .id(element_id::indexed(&base, "trigger", ix))
308 .a11y_named(
309 a11y::Role::MenuItem,
310 &a11y::Name::labelled(menu.label.clone()),
311 )
312 .a11y_expanded(is_open)
313 .relative()
314 .flex()
315 .items_center()
316 .h(px(28.))
317 .px(px(10.))
318 .rounded(radius)
319 .text_color(colors.foreground)
320 .child(menu.label.clone())
321 .child(
322 gpui::canvas(
323 move |bounds, _, _| bounds_probe.set(Some(bounds)),
324 |_, _, _, _| {},
325 )
326 .absolute()
327 .inset_0(),
328 )
329 .debug_selector({
330 let name = format!("menu-bar-trigger-{}", menu.key);
331 move || name
332 });
333 if disabled {
334 trigger = trigger.opacity(0.5);
335 } else {
336 trigger = trigger
337 .track_focus(&handle)
338 .cursor(cursor)
339 .hover(|s| s.bg(colors.default.color))
340 .when(is_open, |t| t.bg(colors.default.color))
341 .when(focused && focus_visible, |t| {
342 t.child(crate::util::focus_ring_overlay(radius, false, cx))
343 });
344 trigger = crate::util::record_focus_bounds(trigger, &handle, window, cx);
345 let press = state.clone();
350 trigger = trigger.on_mouse_down(MouseButton::Left, move |_, window, cx| {
351 crate::util::set_focus_visible(false, cx);
352 press.focus_trigger(ix, window, cx);
353 press.set_open(Some(ix), false, window, cx);
354 cx.stop_propagation();
355 });
356 let hover = state.clone();
357 trigger = trigger.on_hover(move |hovered, window, cx| {
358 let open_now = *hover.open.read(cx);
359 if *hovered && open_now.is_some() && open_now != Some(ix) {
360 hover.focus_trigger(ix, window, cx);
361 hover.set_open(Some(ix), false, window, cx);
362 }
363 });
364 let keys = state.clone();
365 trigger = trigger.on_key_down(move |event, window, cx| {
366 let key = event.keystroke.key.as_str();
367 if !matches!(key, "enter" | "space" | "down") {
368 return;
369 }
370 crate::util::set_focus_visible(true, cx);
371 keys.set_open(Some(ix), true, window, cx);
372 cx.stop_propagation();
373 });
374 }
375 bar = bar.child(trigger);
376 }
377
378 let mut menus = self.menus;
379 let nav = state.clone();
380 let mut root = div()
381 .relative()
382 .flex()
383 .child(bar)
384 .on_key_down(move |event, window, cx| {
385 let m = &event.keystroke.modifiers;
386 if m.control || m.alt || m.platform || m.shift {
387 return;
388 }
389 let key = event.keystroke.key.as_str();
390 let open_now = *nav.open.read(cx);
391 let from =
392 open_now.or_else(|| nav.handles.iter().position(|h| h.is_focused(window)));
393 let Some(from) = from else {
394 return;
395 };
396 let target = match key {
397 "right" => nav.step(from, true),
398 "left" => nav.step(from, false),
399 "home" if open_now.is_none() => nav.enabled.iter().position(|e| *e),
400 "end" if open_now.is_none() => nav.enabled.iter().rposition(|e| *e),
401 _ => return,
402 };
403 let Some(target) = target else {
404 return;
405 };
406 crate::util::set_focus_visible(true, cx);
407 if open_now.is_some() {
408 nav.set_open(Some(target), true, window, cx);
409 nav.set_active(target, cx);
411 } else {
412 nav.focus_trigger(target, window, cx);
413 }
414 cx.stop_propagation();
415 });
416
417 if let (Some(ix), true) = (shown_ix, phase != crate::util::OverlayPhase::Closed) {
418 if ix < menus.len() {
419 let spec = menus.swap_remove(ix);
420 let menu_key = spec.key.clone();
421 let mut menu = Menu::new(
422 element_id::scoped(&base, format!("menu-content-{}", spec.key)),
423 spec.items,
424 )
425 .id(element_id::scoped(&base, format!("menu-{}", spec.key)))
426 .dropdown_composition()
427 .focus_first(focus_first)
428 .exiting(phase == crate::util::OverlayPhase::Exiting)
429 .disabled_keys(spec.disabled_keys)
430 .overlay_token(overlay_token);
431 if let Some(on_action) = self.on_action {
432 menu = menu
433 .on_action(move |key, window, cx| on_action(&menu_key, key, window, cx));
434 }
435 let dismiss = state;
436 menu = menu.on_dismiss(move |_refocus, window, cx| {
437 let was = *dismiss.open.read(cx);
438 dismiss.set_open(None, false, window, cx);
439 if let Some(was) = was {
443 dismiss.focus_trigger(was, window, cx);
444 }
445 });
446 root = root.child(crate::util::floating(
447 crate::popover::popover_with_resolved_placement(
448 trigger_bounds[ix].clone(),
449 Placement::BottomStart,
450 px(4.),
451 None,
452 menu.panel_debug_label("menu-bar-menu"),
453 ),
454 ));
455 }
456 }
457 root
458 }
459}