1#[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
44mod fallback;
47pub(crate) use fallback::FallbackMenuOverlay;
48
49enum NativeMenuItem {
50 Separator,
51 Item {
52 label: SharedString,
53 disabled: bool,
54 checked: bool,
55 icon: Option<Box<Icon>>,
57 action: Option<Box<dyn Action>>,
59 },
60 Submenu {
61 label: SharedString,
62 disabled: bool,
63 items: Vec<NativeMenuItem>,
64 },
65}
66
67#[derive(Default)]
72pub struct NativeMenu {
73 items: Vec<NativeMenuItem>,
74}
75
76impl NativeMenu {
77 pub fn new() -> Self {
79 Self::default()
80 }
81
82 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 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 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 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 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 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 pub fn separator(mut self) -> Self {
177 self.items.push(NativeMenuItem::Separator);
178 self
179 }
180
181 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 pub fn is_empty(&self) -> bool {
193 self.items.is_empty()
194 }
195
196 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 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
321impl 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 #[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 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}