Component 30 of 60
Menubar
A persistent horizontal set of menus.
Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.
fn menubar_preview(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let file = window.use_keyed_state("menubar.file", cx, |_, cx| {
MenuState::new(
[
MenuItem::new("new-tab", "New tab"),
MenuItem::new("new-window", "New window"),
MenuItem::separator(),
MenuItem::new("quit", "Quit"),
],
cx,
)
});
let edit = window.use_keyed_state("menubar.edit", cx, |_, cx| {
MenuState::new(
[
MenuItem::new("undo", "Undo"),
MenuItem::new("redo", "Redo").disabled(true),
MenuItem::new("copy", "Copy"),
],
cx,
)
});
Menubar::new("app-menu", "Application menu")
.menu(&file, "File")
.menu(&edit, "Edit")
}
Installation
After the one-time setup, run the command from your app root. The CLI
writes the modules and keeps src/ui/mod.rs in step.
gpuicn add menubar
If you prefer to copy the files by hand, declare them yourself:
// src/ui/mod.rs
pub mod theme;
pub mod button;
pub mod menu;
pub mod menubar;
// src/main.rs
mod ui;
Usage
Put this in a view module and return example(state) from its render method.
use crate::ui::menu::*;
use crate::ui::menubar::*;
use gpui_kit::{Entity, IntoElement};
fn example(state: &Entity<MenuState>) -> impl IntoElement {
Menubar::new("app-menu", "Application").menu(state, "File")
}
API reference
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self
Creates a `Menubar` with a stable caller-owned ID.
pub fn menu(mut self, state: &Entity<MenuState>, label: impl Into<SharedString>) -> Self
Appends a named trigger backed by its own retained menu state.
Installed source, 127 lines
//! Nova menu bar with one tab stop and keyboard switching between menus.
use super::{
menu::{Menu, MenuState},
theme::{UiTheme, apply_style},
};
use gpui_kit::{
App, ElementId, Entity, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce,
Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
};
#[derive(IntoElement)]
/// A horizontal menu group with one active Tab stop.
pub struct Menubar {
id: ElementId,
label: SharedString,
items: Vec<(Entity<MenuState>, SharedString)>,
style: StyleRefinement,
#[doc(hidden)]
pub role: Role,
}
impl Menubar {
/// Creates a `Menubar` with a stable caller-owned ID.
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
items: Vec::new(),
style: Default::default(),
role: Role::MenuBar,
}
}
/// Appends a named trigger backed by its own retained menu state.
pub fn menu(mut self, state: &Entity<MenuState>, label: impl Into<SharedString>) -> Self {
self.items.push((state.clone(), label.into()));
self
}
}
impl Styled for Menubar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Menubar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let t = UiTheme::read(cx).clone();
let states: Vec<_> = self.items.iter().map(|(state, _)| state.clone()).collect();
let active = window.use_keyed_state((self.id.clone(), "active"), cx, |_, _| 0_usize);
let current = states
.iter()
.position(|s| s.read(cx).is_open() || s.read(cx).trigger_focus().is_focused(window))
.unwrap_or(*active.read(cx))
.min(states.len().saturating_sub(1));
active.update(cx, |value, _| *value = current);
for (i, state) in states.iter().enumerate() {
state.read(cx).trigger_focus().tab_stop(i == current);
}
let mut root = div()
.id(self.id)
.role(self.role)
.aria_label(self.label)
.flex()
.items_center()
.gap(t.space(0.5))
.p(t.space(0.75))
.border_1()
.border_color(t.colors.border)
.rounded(t.radius.lg)
.bg(t.colors.background);
for (index, (state, label)) in self.items.into_iter().enumerate() {
let siblings = states.clone();
root = root.child(
div()
.id(("menubar-entry", state.entity_id()))
.on_hover(move |hovered, window, cx| {
if !*hovered {
return;
}
let open = siblings.iter().position(|s| s.read(cx).is_open());
if let Some(open) = open.filter(|&i| i != index) {
siblings[open].update(cx, |s, cx| s.close(false, window, cx));
siblings[index].update(cx, |s, cx| s.open(window, cx));
}
})
.child(Menu::new(&state, label)),
);
}
apply_style(
root.capture_key_down(move |event, window, cx| {
if event.keystroke.modifiers.modified() || states.is_empty() {
return;
}
let key = event.keystroke.key.as_str();
let Some(current) = states.iter().position(|s| {
s.read(cx).is_open() || s.read(cx).trigger_focus().is_focused(window)
}) else {
return;
};
let open = states[current].read(cx).is_open();
if open
&& ((key == "left" && states[current].read(cx).submenu_key(false))
|| (key == "right" && states[current].read(cx).submenu_key(true)))
{
return;
}
let next = match key {
"left" => (current + states.len() - 1) % states.len(),
"right" => (current + 1) % states.len(),
"home" if !open => 0,
"end" if !open => states.len() - 1,
_ => return,
};
states[current].read(cx).trigger_focus().tab_stop(false);
let target = states[next].read(cx).trigger_focus().tab_stop(true);
states[current].update(cx, |s, cx| s.close(false, window, cx));
if open {
states[next].update(cx, |s, cx| s.open(window, cx));
} else {
target.focus(window, cx);
}
active.update(cx, |value, _| *value = next);
window.refresh();
cx.stop_propagation();
}),
&self.style,
)
}
}
Platform notes
Visual reference: shadcn/ui 4.19.0, Neutral Nova. Implementation:
crates/ui/registry/menubar/menubar.rs, using GPUI Kit 0.6.4.
Menubar composes retained MenuState entities. It maintains one Tab stop across triggers, moves between them with Left/Right and switches open menus on hover. Each popup uses Menu keyboard navigation and focus restoration.
The Rust source and Usage example define the supported API. Browser DOM/CSS behavior, exact exit animations and cross-platform screen-reader parity are not implied by the native component. See migration qualification for the checks completed on this revision.