Skip to main content

gpui_component/native_menu/
mod.rs

1//! A menu rendered natively by the operating system.
2//!
3//! Unlike [`crate::menu::PopupMenu`], which is drawn by GPUI and therefore
4//! clipped to the window bounds, [`NativeMenu`] is rendered by the OS. It can
5//! extend beyond the window — useful for small windows where a GPUI-drawn popup
6//! menu would otherwise be cut off.
7//!
8//! Items carry a GPUI [`Action`], dispatched via [`Window::dispatch_action`]
9//! when selected — the same mechanism the application menu bar and key bindings
10//! use. A [`NativeMenu`] can therefore be built directly from GPUI
11//! [`gpui::MenuItem`]s (see [`NativeMenu::from_menu_items`] /
12//! [`From<gpui::Menu>`]).
13//!
14//! ```ignore
15//! use gpui_kit::component::native_menu::NativeMenu;
16//!
17//! NativeMenu::new()
18//!     .menu("Copy", Box::new(Copy))
19//!     .menu("Paste", Box::new(Paste))
20//!     .separator()
21//!     .menu("Delete", Box::new(Delete))
22//!     .show(position, window, cx);
23//! ```
24
25#[cfg(target_os = "windows")]
26use crate::ActiveTheme as _;
27use crate::Icon;
28#[cfg(any(target_os = "macos", target_os = "windows", test))]
29use crate::icon::IconSource;
30
31#[cfg(any(target_os = "macos", target_os = "windows"))]
32use gpui::AssetSource;
33use gpui::{Action, App, Pixels, Point, SharedString, Window};
34#[cfg(any(target_os = "macos", target_os = "windows"))]
35use gpui::{Image, ImageFormat};
36#[cfg(any(target_os = "macos", target_os = "windows"))]
37use std::{path::Path, sync::Arc};
38
39#[cfg(target_os = "macos")]
40mod macos;
41#[cfg(target_os = "windows")]
42mod windows;
43
44// Drawn-menu fallback (used on platforms without an OS-native popup, e.g. Linux).
45// Compiled on all platforms because `Root` holds the overlay entity.
46mod fallback;
47pub(crate) use fallback::FallbackMenuOverlay;
48
49enum NativeMenuItem {
50    Separator,
51    Item {
52        label: SharedString,
53        disabled: bool,
54        checked: bool,
55        /// Icon shown next to the label.
56        icon: Option<Box<Icon>>,
57        /// Action dispatched when the item is selected.
58        action: Option<Box<dyn Action>>,
59    },
60    Submenu {
61        label: SharedString,
62        disabled: bool,
63        items: Vec<NativeMenuItem>,
64    },
65}
66
67/// A menu rendered by the operating system.
68///
69/// Build it with the [`NativeMenu::menu`] / [`NativeMenu::separator`] builders,
70/// then call [`NativeMenu::show`] to display it at a position.
71#[derive(Default)]
72pub struct NativeMenu {
73    items: Vec<NativeMenuItem>,
74}
75
76impl NativeMenu {
77    /// Create an empty native menu.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Append a clickable item that dispatches `action` when selected.
83    pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
84        self.menu_with(label, false, false, None, Some(action))
85    }
86
87    /// Append an item, controlling its `disabled` state.
88    pub fn menu_with_disabled(
89        self,
90        label: impl Into<SharedString>,
91        disabled: bool,
92        action: Box<dyn Action>,
93    ) -> Self {
94        self.menu_with(label, disabled, false, None, Some(action))
95    }
96
97    /// Append an item, controlling its `checked` state (a check mark is shown).
98    pub fn menu_with_check(
99        self,
100        label: impl Into<SharedString>,
101        checked: bool,
102        action: Box<dyn Action>,
103    ) -> Self {
104        self.menu_with(label, false, checked, None, Some(action))
105    }
106
107    /// Append an item showing `icon` next to its label.
108    ///
109    /// Native platform menus load absolute paths ([`Path::is_absolute`]) from the filesystem,
110    /// and every other path through the application [`gpui::AssetSource`].
111    /// Icons created with [`Icon::data`] use their SVG bytes directly, without an asset lookup.
112    /// [`crate::IconName`] resolves as an asset and works across all backends.
113    /// - **macOS**: loaded into an `NSImage` as a template image, so it tints with the item
114    /// text and assigned to the item ([`NSMenuItem::image`]).
115    /// - **Windows**: loaded into an `HBITMAP` and set as the item's
116    /// content bitmap (`MENUITEMINFOW::hbmpItem`), shown beside the label. SVG files are
117    /// rasterized, with `resvg`; other formats (PNG, JPEG, BMP, ...) are decoded by GDI+.
118    /// **Other platforms** (fallback): rendered as the menu item's [`crate::Icon`].
119    ///
120    /// Note: this is the menu item's *content* icon, not its state/check-mark indicator.
121    pub fn menu_with_icon(
122        self,
123        label: impl Into<SharedString>,
124        icon: impl Into<Icon>,
125        action: Box<dyn Action>,
126    ) -> Self {
127        self.menu_with(label, false, false, Some(icon.into()), Some(action))
128    }
129
130    /// Append an item showing `icon` next to its label, controlling its `disabled` state.
131    ///
132    /// Same icon behavior as [`Self::menu_with_icon`]. Use this when an item
133    /// carries an icon but should be greyed out.
134    pub fn menu_with_icon_disabled(
135        self,
136        label: impl Into<SharedString>,
137        icon: impl Into<Icon>,
138        disabled: bool,
139        action: Box<dyn Action>,
140    ) -> Self {
141        self.menu_with(label, disabled, false, Some(icon.into()), Some(action))
142    }
143
144    /// Add Menu Item with Icon and disabled state.
145    ///
146    /// Alias for [`Self::menu_with_icon_disabled`], matching [`crate::menu::PopupMenu`].
147    pub fn menu_with_icon_and_disabled(
148        self,
149        label: impl Into<SharedString>,
150        icon: impl Into<Icon>,
151        action: Box<dyn Action>,
152        disabled: bool,
153    ) -> Self {
154        self.menu_with_icon_disabled(label, icon, disabled, action)
155    }
156
157    fn menu_with(
158        mut self,
159        label: impl Into<SharedString>,
160        disabled: bool,
161        checked: bool,
162        icon: Option<Icon>,
163        action: Option<Box<dyn Action>>,
164    ) -> Self {
165        self.items.push(NativeMenuItem::Item {
166            label: label.into(),
167            disabled,
168            checked,
169            icon: icon.map(Box::new),
170            action,
171        });
172        self
173    }
174
175    /// Append a separator line.
176    pub fn separator(mut self) -> Self {
177        self.items.push(NativeMenuItem::Separator);
178        self
179    }
180
181    /// Append a submenu built from another [`NativeMenu`].
182    pub fn submenu(mut self, label: impl Into<SharedString>, submenu: NativeMenu) -> Self {
183        self.items.push(NativeMenuItem::Submenu {
184            label: label.into(),
185            disabled: false,
186            items: submenu.items,
187        });
188        self
189    }
190
191    /// Whether the menu has no items.
192    pub fn is_empty(&self) -> bool {
193        self.items.is_empty()
194    }
195
196    /// Pop up the menu at `position` (window coordinates, in logical pixels).
197    ///
198    /// The menu is shown without blocking the caller: the OS tracking loop runs
199    /// off GPUI's call stack, so GPUI is not borrowed while it is open. When an
200    /// item is selected, its action is dispatched via [`Window::dispatch_action`].
201    pub fn show(self, position: Point<Pixels>, window: &mut Window, cx: &mut App) {
202        if self.items.is_empty() {
203            return;
204        }
205
206        #[cfg(target_os = "macos")]
207        {
208            macos::show(self.items, cx.asset_source().clone(), position, window, cx);
209        }
210        #[cfg(target_os = "windows")]
211        {
212            windows::show(
213                self.items,
214                cx.asset_source().clone(),
215                position,
216                cx.theme().is_dark(),
217                window,
218                cx,
219            );
220        }
221        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
222        fallback::show(self.items, position, window, cx);
223    }
224}
225
226#[cfg(any(target_os = "macos", target_os = "windows"))]
227pub(super) fn resolve_icon_image(
228    icon: &Icon,
229    asset_source: &dyn AssetSource,
230) -> Option<Arc<Image>> {
231    let path = match icon.source_ref() {
232        IconSource::Path(path) => path,
233        IconSource::Data(bytes) => {
234            return Some(Arc::new(Image::from_bytes(
235                ImageFormat::Svg,
236                bytes.to_vec(),
237            )));
238        }
239    };
240    if path.is_empty() {
241        return None;
242    }
243
244    let icon_path = Path::new(path.as_ref());
245    // Relative paths are asset identifiers and must not resolve against the process CWD.
246    let bytes = if icon_path.is_absolute() {
247        std::fs::read(icon_path).ok()?
248    } else {
249        asset_source
250            .load(path.as_ref())
251            .ok()
252            .flatten()?
253            .into_owned()
254    };
255    let format = image_format(path.as_ref(), &bytes)?;
256    Some(Arc::new(Image::from_bytes(format, bytes)))
257}
258
259#[cfg(any(target_os = "macos", target_os = "windows"))]
260fn image_format(path: &str, bytes: &[u8]) -> Option<ImageFormat> {
261    if let Some(extension) = Path::new(path)
262        .extension()
263        .and_then(|extension| extension.to_str())
264    {
265        let format = match extension.to_ascii_lowercase().as_str() {
266            "png" => ImageFormat::Png,
267            "jpg" | "jpeg" => ImageFormat::Jpeg,
268            "webp" => ImageFormat::Webp,
269            "gif" => ImageFormat::Gif,
270            "svg" => ImageFormat::Svg,
271            "bmp" => ImageFormat::Bmp,
272            "tif" | "tiff" => ImageFormat::Tiff,
273            "ico" => ImageFormat::Ico,
274            "pbm" | "pgm" | "ppm" | "pnm" => ImageFormat::Pnm,
275            _ => return None,
276        };
277        return Some(format);
278    }
279
280    image_format_from_bytes(bytes)
281}
282
283#[cfg(any(target_os = "macos", target_os = "windows"))]
284fn image_format_from_bytes(bytes: &[u8]) -> Option<ImageFormat> {
285    let bytes = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes);
286    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
287        Some(ImageFormat::Png)
288    } else if bytes.starts_with(b"\xff\xd8\xff") {
289        Some(ImageFormat::Jpeg)
290    } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
291        Some(ImageFormat::Gif)
292    } else if bytes.starts_with(b"BM") {
293        Some(ImageFormat::Bmp)
294    } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
295        Some(ImageFormat::Webp)
296    } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
297        Some(ImageFormat::Tiff)
298    } else if bytes.starts_with(b"\0\0\x01\0") || bytes.starts_with(b"\0\0\x02\0") {
299        Some(ImageFormat::Ico)
300    } else if is_svg_bytes(bytes) {
301        Some(ImageFormat::Svg)
302    } else if matches!(
303        bytes.get(0..2),
304        Some(b"P1" | b"P2" | b"P3" | b"P4" | b"P5" | b"P6")
305    ) {
306        Some(ImageFormat::Pnm)
307    } else {
308        None
309    }
310}
311
312#[cfg(any(target_os = "macos", target_os = "windows"))]
313fn is_svg_bytes(bytes: &[u8]) -> bool {
314    let text = match std::str::from_utf8(&bytes[..bytes.len().min(256)]) {
315        Ok(text) => text.trim_start(),
316        Err(_) => return false,
317    };
318    text.starts_with("<svg") || text.starts_with("<?xml")
319}
320
321/// Reuse an existing GPUI menu definition as a native menu.
322///
323/// `Action`s, separators, submenus, `checked`, and `disabled` are mapped over;
324/// system menus (e.g. macOS Services) have no native popup equivalent and are
325/// skipped.
326impl From<gpui::Menu> for NativeMenu {
327    fn from(menu: gpui::Menu) -> Self {
328        let mut native = Self::new();
329        for item in menu.items {
330            match item {
331                gpui::MenuItem::Separator => native.items.push(NativeMenuItem::Separator),
332                gpui::MenuItem::Action {
333                    name,
334                    action,
335                    checked,
336                    disabled,
337                    ..
338                } => native.items.push(NativeMenuItem::Item {
339                    label: name,
340                    disabled,
341                    checked,
342                    icon: None,
343                    action: Some(action),
344                }),
345                gpui::MenuItem::Submenu(submenu) => native.items.push(NativeMenuItem::Submenu {
346                    label: submenu.name.clone(),
347                    disabled: submenu.disabled,
348                    items: Self::from(submenu).items,
349                }),
350                gpui::MenuItem::SystemMenu(_) => {}
351            }
352        }
353        native
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::IconName;
361    use serde::Deserialize;
362
363    #[derive(Action, Clone, PartialEq, Deserialize)]
364    #[action(namespace = native_menu_tests, no_json)]
365    struct TestAction;
366
367    #[test]
368    fn test_native_menu_builder_accepts_icon() {
369        let menu =
370            NativeMenu::new().menu_with_icon("Github", IconName::Github, Box::new(TestAction));
371
372        assert_eq!(menu.items.len(), 1);
373        let NativeMenuItem::Item {
374            label,
375            disabled,
376            checked,
377            icon: Some(icon),
378            action: Some(_),
379        } = &menu.items[0]
380        else {
381            panic!("expected an actionable item with an icon");
382        };
383
384        assert_eq!(label, "Github");
385        assert!(!disabled);
386        assert!(!checked);
387        assert!(matches!(icon.source_ref(), IconSource::Path(path) if path == "icons/github.svg"));
388    }
389
390    #[test]
391    fn test_native_menu_builder_accepts_icon_and_disabled_alias() {
392        let menu = NativeMenu::new().menu_with_icon_and_disabled(
393            "Inbox",
394            IconName::Inbox,
395            Box::new(TestAction),
396            true,
397        );
398
399        assert_eq!(menu.items.len(), 1);
400        let NativeMenuItem::Item {
401            label,
402            disabled,
403            checked,
404            icon: Some(icon),
405            action: Some(_),
406        } = &menu.items[0]
407        else {
408            panic!("expected a disabled actionable item with an icon");
409        };
410
411        assert_eq!(label, "Inbox");
412        assert!(disabled);
413        assert!(!checked);
414        assert!(matches!(icon.source_ref(), IconSource::Path(path) if path.ends_with("inbox.svg")));
415    }
416
417    /// Icon resolution is only compiled for the platforms with an OS-native menu.
418    #[cfg(any(target_os = "macos", target_os = "windows"))]
419    mod icon_resolution {
420        use super::*;
421        use std::{borrow::Cow, fs, path::PathBuf};
422
423        const ASSET_SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#;
424        const FILE_SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg"><path/></svg>"#;
425
426        struct TestAssetSource(Option<&'static [u8]>);
427
428        impl AssetSource for TestAssetSource {
429            fn load(&self, _path: &str) -> gpui::Result<Option<Cow<'static, [u8]>>> {
430                Ok(self.0.map(Cow::Borrowed))
431            }
432
433            fn list(&self, _path: &str) -> gpui::Result<Vec<SharedString>> {
434                Ok(Vec::new())
435            }
436        }
437
438        /// A file written into the current directory for the duration of one test.
439        ///
440        /// The shadowing tests need a file the process would find by walking a *relative*
441        /// path, so it has to live in the current directory rather than a temporary one.
442        /// Nothing else is created alongside it, so dropping the file leaves no residue.
443        struct TestIconFile(PathBuf);
444
445        impl TestIconFile {
446            fn create(path: impl Into<PathBuf>, bytes: &[u8]) -> Self {
447                let path = path.into();
448                assert!(
449                    !path.exists(),
450                    "leftover test file, delete it and re-run: {}",
451                    path.display()
452                );
453                fs::write(&path, bytes).expect("test file should be written");
454                Self(path)
455            }
456        }
457
458        impl Drop for TestIconFile {
459            fn drop(&mut self) {
460                let _ = fs::remove_file(&self.0);
461            }
462        }
463
464        #[test]
465        fn test_native_menu_icon_asset_resolves_to_bytes() {
466            let icon = Icon::new(IconName::Github);
467            let image = resolve_icon_image(&icon, &gpui_kit_assets::Assets)
468                .expect("icon asset should resolve");
469
470            assert_eq!(image.format, ImageFormat::Svg);
471            assert!(!image.bytes.is_empty());
472        }
473
474        #[test]
475        fn test_relative_icon_path_only_uses_asset_source() {
476            let path: SharedString = "native-menu-relative-shadow-test.svg".into();
477            let _file = TestIconFile::create(path.as_ref(), FILE_SVG);
478
479            let icon = Icon::default().path(path);
480            let image = resolve_icon_image(&icon, &TestAssetSource(Some(ASSET_SVG)))
481                .expect("relative icon should resolve from the asset source");
482            assert_eq!(image.bytes, ASSET_SVG);
483
484            assert!(resolve_icon_image(&icon, &TestAssetSource(None)).is_none());
485        }
486
487        #[test]
488        fn test_absolute_icon_path_loads_from_filesystem() {
489            let path = std::env::current_dir()
490                .expect("test current directory should be available")
491                .join("native-menu-absolute-path-test.svg");
492            let _file = TestIconFile::create(&path, FILE_SVG);
493            let path: SharedString = path.to_string_lossy().into_owned().into();
494
495            let image = resolve_icon_image(
496                &Icon::default().path(path),
497                &TestAssetSource(Some(ASSET_SVG)),
498            )
499            .expect("absolute icon should resolve from the filesystem");
500            assert_eq!(image.bytes, FILE_SVG);
501        }
502
503        #[test]
504        fn test_native_menu_icon_data_replaces_path_and_survives_clone() {
505            let icon = Icon::default().path("icons/previous.png").data(FILE_SVG);
506            let image = resolve_icon_image(&icon.clone(), &TestAssetSource(None))
507                .expect("SVG data should resolve without an asset source");
508            assert_eq!(image.format, ImageFormat::Svg);
509            assert_eq!(image.bytes, FILE_SVG);
510
511            let icon = icon.path("icons/replacement.svg");
512            let image = resolve_icon_image(&icon, &TestAssetSource(Some(ASSET_SVG)))
513                .expect("a later path should replace the data source");
514            assert_eq!(image.bytes, ASSET_SVG);
515        }
516    }
517}