1#[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
42mod fallback;
45pub(crate) use fallback::FallbackMenuOverlay;
46
47enum NativeMenuItem {
48 Separator,
49 Item {
50 label: SharedString,
51 disabled: bool,
52 checked: bool,
53 icon: Option<Box<Icon>>,
55 action: Option<Box<dyn Action>>,
57 },
58 Submenu {
59 label: SharedString,
60 disabled: bool,
61 items: Vec<NativeMenuItem>,
62 },
63}
64
65#[derive(Default)]
70pub struct NativeMenu {
71 items: Vec<NativeMenuItem>,
72}
73
74impl NativeMenu {
75 pub fn new() -> Self {
77 Self::default()
78 }
79
80 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 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 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 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 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 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 pub fn separator(mut self) -> Self {
174 self.items.push(NativeMenuItem::Separator);
175 self
176 }
177
178 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 pub fn is_empty(&self) -> bool {
190 self.items.is_empty()
191 }
192
193 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 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
309impl 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 #[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 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}