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