Skip to main content

ui/components/
navigation_menu.rs

1//! Navigation menu with animated viewport for submenu content.
2//!
3//! Uses simplified hover-intent: submenus open on trigger hover and close when
4//! the pointer leaves the menu root (no close-delay timer in this version).
5
6use std::cell::Cell;
7use std::rc::Rc;
8
9use gpui::{AnyElement, Bounds, Context, Pixels, Render, anchored, canvas, deferred, point};
10use smallvec::SmallVec;
11
12use crate::prelude::*;
13
14/// A single submenu entry shown inside a [`NavigationMenuContent`] viewport.
15#[derive(Clone)]
16pub struct NavigationMenuSubItem {
17    pub label: SharedString,
18}
19
20/// Top-level navigation menu item definition.
21#[derive(Clone)]
22pub struct NavigationMenuItemDef {
23    pub label: SharedString,
24    pub items: Vec<NavigationMenuSubItem>,
25}
26
27/// Root container for a horizontal navigation menu.
28#[derive(IntoElement)]
29pub struct NavigationMenuList {
30    children: SmallVec<[AnyElement; 4]>,
31}
32
33impl NavigationMenuList {
34    pub fn new() -> Self {
35        Self {
36            children: SmallVec::new(),
37        }
38    }
39}
40
41impl Default for NavigationMenuList {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl ParentElement for NavigationMenuList {
48    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
49        self.children.extend(elements);
50    }
51}
52
53impl RenderOnce for NavigationMenuList {
54    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
55        h_flex().items_center().gap_1().children(self.children)
56    }
57}
58
59/// A navigation menu trigger button.
60#[derive(IntoElement)]
61pub struct NavigationMenuTrigger {
62    label: SharedString,
63    active: bool,
64}
65
66impl NavigationMenuTrigger {
67    pub fn new(label: impl Into<SharedString>) -> Self {
68        Self {
69            label: label.into(),
70            active: false,
71        }
72    }
73
74    pub fn active(mut self, active: bool) -> Self {
75        self.active = active;
76        self
77    }
78}
79
80impl RenderOnce for NavigationMenuTrigger {
81    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
82        if self.active {
83            Button::new("nav-menu-trigger", self.label)
84                .primary()
85                .style(ButtonStyle::Filled)
86        } else {
87            Button::new("nav-menu-trigger", self.label).style(ButtonStyle::Transparent)
88        }
89    }
90}
91
92/// Viewport panel that displays the active submenu's links.
93#[derive(IntoElement)]
94pub struct NavigationMenuViewport {
95    children: SmallVec<[AnyElement; 6]>,
96}
97
98impl NavigationMenuViewport {
99    pub fn new() -> Self {
100        Self {
101            children: SmallVec::new(),
102        }
103    }
104}
105
106impl Default for NavigationMenuViewport {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl ParentElement for NavigationMenuViewport {
113    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
114        self.children.extend(elements);
115    }
116}
117
118impl RenderOnce for NavigationMenuViewport {
119    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
120        div()
121            .w_full()
122            .p_4()
123            .rounded_md()
124            .border_1()
125            .border_color(semantic::border(cx))
126            .bg(semantic::elevated_surface(cx))
127            .shadow_level(Shadow::Md)
128            .child(h_flex().gap_6().children(self.children))
129    }
130}
131
132/// A link-style item inside a navigation menu viewport.
133#[derive(IntoElement)]
134pub struct NavigationMenuLink {
135    label: SharedString,
136    on_click: Option<Rc<dyn Fn(&mut Window, &mut App) + 'static>>,
137}
138
139impl NavigationMenuLink {
140    pub fn new(label: impl Into<SharedString>) -> Self {
141        Self {
142            label: label.into(),
143            on_click: None,
144        }
145    }
146
147    pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
148        self.on_click = Some(Rc::new(handler));
149        self
150    }
151}
152
153impl RenderOnce for NavigationMenuLink {
154    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
155        let hover = semantic::hover_bg(cx);
156        let handler = self.on_click;
157        h_flex()
158            .id(ElementId::Name(self.label.clone()))
159            .px_3()
160            .py_2()
161            .rounded_md()
162            .cursor_pointer()
163            .hover(move |s| s.bg(hover))
164            .when_some(handler, |this, handler| {
165                this.on_click(move |_, window, cx| handler(window, cx))
166            })
167            .child(Label::new(self.label))
168    }
169}
170
171/// Content wrapper for a single submenu column inside the viewport.
172#[derive(IntoElement)]
173pub struct NavigationMenuContent {
174    title: SharedString,
175    children: SmallVec<[AnyElement; 4]>,
176}
177
178impl NavigationMenuContent {
179    pub fn new(title: impl Into<SharedString>) -> Self {
180        Self {
181            title: title.into(),
182            children: SmallVec::new(),
183        }
184    }
185}
186
187impl ParentElement for NavigationMenuContent {
188    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
189        self.children.extend(elements);
190    }
191}
192
193impl RenderOnce for NavigationMenuContent {
194    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
195        v_flex()
196            .gap_2()
197            .min_w(px(160.))
198            .child(
199                Label::new(self.title)
200                    .size(LabelSize::Small)
201                    .weight(gpui::FontWeight::SEMIBOLD),
202            )
203            .children(self.children)
204    }
205}
206
207/// Stateful navigation menu with hover-driven submenu viewport.
208///
209/// Create with `cx.new(|_| NavigationMenu::new(items))`.
210pub struct NavigationMenu {
211    items: Vec<NavigationMenuItemDef>,
212    open_index: Option<usize>,
213    /// Real screen bounds of the trigger row, captured via an invisible
214    /// `canvas()` measurement child every render and read back on the
215    /// *next* render to position the floating viewport. Same idiom as
216    /// `Combobox::trigger_bounds` / `Command::trigger_bounds`
217    /// (`crates/ui/src/components/combobox.rs`,
218    /// `crates/ui/src/components/command.rs`).
219    trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
220}
221
222impl NavigationMenu {
223    pub fn new(items: Vec<NavigationMenuItemDef>) -> Self {
224        Self {
225            items,
226            open_index: None,
227            trigger_bounds: Rc::new(Cell::new(None)),
228        }
229    }
230
231    pub fn open_index(&self) -> Option<usize> {
232        self.open_index
233    }
234}
235
236impl Render for NavigationMenu {
237    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
238        let open_index = self.open_index;
239
240        let triggers = self.items.iter().enumerate().map(|(index, item)| {
241            let is_open = open_index == Some(index);
242            h_flex()
243                .id(("navigation-menu-item", index))
244                .on_hover(cx.listener(move |this, &hovered, _, cx| {
245                    if hovered {
246                        this.open_index = Some(index);
247                    }
248                    cx.notify();
249                }))
250                .child(NavigationMenuTrigger::new(item.label.clone()).active(is_open))
251        });
252
253        let viewport = open_index
254            .and_then(|index| self.items.get(index))
255            .map(|item| {
256                NavigationMenuViewport::new().child(
257                    NavigationMenuContent::new(item.label.clone()).children(
258                        item.items
259                            .iter()
260                            .map(|sub| NavigationMenuLink::new(sub.label.clone())),
261                    ),
262                )
263            });
264
265        // Measure the trigger row's real bounds via an invisible `canvas()`
266        // overlay, then float the viewport in a `deferred` overlay pass
267        // anchored just below those bounds, instead of an inline flow child
268        // — so opening a submenu never pushes sibling content down. Same
269        // idiom as `Combobox`/`Command`
270        // (`crates/ui/src/components/combobox.rs`,
271        // `crates/ui/src/components/command.rs`).
272        let trigger_bounds = self.trigger_bounds.clone();
273        let trigger_row = div()
274            .child(NavigationMenuList::new().children(triggers))
275            .child({
276                let trigger_bounds = trigger_bounds.clone();
277                canvas(
278                    move |bounds, _window, _cx| trigger_bounds.set(Some(bounds)),
279                    |_bounds, _state, _window, _cx| {},
280                )
281                .absolute()
282                .top_0()
283                .left_0()
284                .size_full()
285            });
286
287        let floating_viewport = viewport.map(|viewport| {
288            let mut anchor = anchored().snap_to_window_with_margin(px(8.));
289            if let Some(bounds) = self.trigger_bounds.get() {
290                anchor = anchor.position(point(
291                    bounds.origin.x,
292                    bounds.origin.y + bounds.size.height + px(4.),
293                ));
294            }
295            deferred(anchor.child(div().occlude().child(viewport))).with_priority(1)
296        });
297
298        v_flex()
299            .id("navigation-menu")
300            .gap_2()
301            .on_hover(cx.listener(|this, hovered: &bool, _, cx| {
302                if !hovered {
303                    this.open_index = None;
304                    cx.notify();
305                }
306            }))
307            .child(trigger_row)
308            .when_some(floating_viewport, |this, floating_viewport| {
309                this.child(floating_viewport)
310            })
311    }
312}
313
314/// Gallery catalog entry for [`NavigationMenu`].
315#[derive(IntoElement, RegisterComponent)]
316pub struct NavigationMenuPreview;
317
318impl RenderOnce for NavigationMenuPreview {
319    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
320        cx.new(|_| {
321            NavigationMenu::new(vec![
322                NavigationMenuItemDef {
323                    label: "Getting Started".into(),
324                    items: vec![
325                        NavigationMenuSubItem {
326                            label: "Introduction".into(),
327                        },
328                        NavigationMenuSubItem {
329                            label: "Installation".into(),
330                        },
331                    ],
332                },
333                NavigationMenuItemDef {
334                    label: "Components".into(),
335                    items: vec![
336                        NavigationMenuSubItem {
337                            label: "Button".into(),
338                        },
339                        NavigationMenuSubItem {
340                            label: "Dialog".into(),
341                        },
342                        NavigationMenuSubItem {
343                            label: "Table".into(),
344                        },
345                    ],
346                },
347            ])
348        })
349    }
350}
351
352impl Component for NavigationMenuPreview {
353    fn scope() -> ComponentScope {
354        ComponentScope::Navigation
355    }
356
357    fn description() -> Option<&'static str> {
358        Some("Horizontal navigation menu with hover-intent submenu viewport.")
359    }
360
361    fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
362        NavigationMenuPreview
363            .render(window, cx)
364            .into_any_element()
365            .into()
366    }
367}