Merge pull request #10 from zed-industries/menus

Nathan Sobo created

Populate the menu bar

Change summary

Cargo.lock                      |  18 +--
Cargo.toml                      |   6 +
gpui/src/app.rs                 |  18 ++++
gpui/src/platform/mac/app.rs    |  52 ++++++++++-
gpui/src/platform/mac/runner.rs | 147 +++++++++++++++++++++++++++++++---
gpui/src/platform/mod.rs        |  14 ++
gpui/src/platform/test.rs       |   6 +
zed/src/lib.rs                  |   1 
zed/src/main.rs                 |  41 +++++++--
zed/src/menus.rs                |  60 ++++++++++++++
zed/src/workspace/mod.rs        |   5 +
11 files changed, 323 insertions(+), 45 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -399,8 +399,7 @@ dependencies = [
 [[package]]
 name = "cocoa"
 version = "0.24.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f63902e9223530efb4e26ccd0cf55ec30d592d3b42e21a28defc42a9586e832"
+source = "git+https://github.com/servo/core-foundation-rs?rev=e9a65bb15d591ec22649e03659db8095d4f2dd60#e9a65bb15d591ec22649e03659db8095d4f2dd60"
 dependencies = [
  "bitflags",
  "block",
@@ -415,8 +414,7 @@ dependencies = [
 [[package]]
 name = "cocoa-foundation"
 version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ade49b65d560ca58c403a479bb396592b155c0185eada742ee323d1d68d6318"
+source = "git+https://github.com/servo/core-foundation-rs?rev=e9a65bb15d591ec22649e03659db8095d4f2dd60#e9a65bb15d591ec22649e03659db8095d4f2dd60"
 dependencies = [
  "bitflags",
  "block",
@@ -445,8 +443,7 @@ checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
 [[package]]
 name = "core-foundation"
 version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0a89e2ae426ea83155dccf10c0fa6b1463ef6d5fcb44cee0b224a408fa640a62"
+source = "git+https://github.com/servo/core-foundation-rs?rev=e9a65bb15d591ec22649e03659db8095d4f2dd60#e9a65bb15d591ec22649e03659db8095d4f2dd60"
 dependencies = [
  "core-foundation-sys",
  "libc",
@@ -455,14 +452,12 @@ dependencies = [
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b"
+source = "git+https://github.com/servo/core-foundation-rs?rev=e9a65bb15d591ec22649e03659db8095d4f2dd60#e9a65bb15d591ec22649e03659db8095d4f2dd60"
 
 [[package]]
 name = "core-graphics"
 version = "0.22.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "269f35f69b542b80e736a20a89a05215c0ce80c2c03c514abb2e318b78379d86"
+source = "git+https://github.com/servo/core-foundation-rs?rev=e9a65bb15d591ec22649e03659db8095d4f2dd60#e9a65bb15d591ec22649e03659db8095d4f2dd60"
 dependencies = [
  "bitflags",
  "core-foundation",
@@ -474,8 +469,7 @@ dependencies = [
 [[package]]
 name = "core-graphics-types"
 version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a68b68b3446082644c91ac778bf50cd4104bfb002b5a6a7c44cca5a2c70788b"
+source = "git+https://github.com/servo/core-foundation-rs?rev=e9a65bb15d591ec22649e03659db8095d4f2dd60#e9a65bb15d591ec22649e03659db8095d4f2dd60"
 dependencies = [
  "bitflags",
  "core-foundation",

Cargo.toml πŸ”—

@@ -3,3 +3,9 @@ members = ["zed", "gpui"]
 
 [patch.crates-io]
 async-task = {git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e"}
+
+# TODO - Remove when a version is released with this PR: https://github.com/servo/core-foundation-rs/pull/454
+cocoa = {git = "https://github.com/servo/core-foundation-rs", rev = "e9a65bb15d591ec22649e03659db8095d4f2dd60"}
+cocoa-foundation = {git = "https://github.com/servo/core-foundation-rs", rev = "e9a65bb15d591ec22649e03659db8095d4f2dd60"}
+core-foundation = {git = "https://github.com/servo/core-foundation-rs", rev = "e9a65bb15d591ec22649e03659db8095d4f2dd60"}
+core-graphics = {git = "https://github.com/servo/core-foundation-rs", rev = "e9a65bb15d591ec22649e03659db8095d4f2dd60"}

gpui/src/app.rs πŸ”—

@@ -66,6 +66,20 @@ pub trait UpdateView {
         F: FnOnce(&mut T, &mut ViewContext<T>) -> S;
 }
 
+pub struct Menu<'a> {
+    pub name: &'a str,
+    pub items: &'a [MenuItem<'a>],
+}
+
+pub enum MenuItem<'a> {
+    Action {
+        name: &'a str,
+        keystroke: Option<&'a str>,
+        action: &'a str,
+    },
+    Separator,
+}
+
 #[derive(Clone)]
 pub struct App(Rc<RefCell<MutableAppContext>>);
 
@@ -367,6 +381,10 @@ impl MutableAppContext {
         &self.ctx
     }
 
+    pub fn platform(&self) -> Arc<dyn platform::App> {
+        self.platform.clone()
+    }
+
     pub fn foreground_executor(&self) -> &Rc<executor::Foreground> {
         &self.foreground
     }

gpui/src/platform/mac/app.rs πŸ”—

@@ -2,12 +2,12 @@ use super::{BoolExt as _, Dispatcher, FontSystem, Window};
 use crate::{executor, platform};
 use anyhow::Result;
 use cocoa::{
-    appkit::{NSPasteboard, NSPasteboardTypeString},
-    base::{id, nil},
-    foundation::NSData,
+    appkit::{NSApplication, NSModalResponse, NSOpenPanel, NSPasteboard, NSPasteboardTypeString},
+    base::nil,
+    foundation::{NSArray, NSData, NSString, NSURL},
 };
-use objc::{class, msg_send, sel, sel_impl};
-use std::{ffi::c_void, rc::Rc, sync::Arc};
+use objc::{msg_send, sel, sel_impl};
+use std::{ffi::c_void, path::PathBuf, rc::Rc, sync::Arc};
 
 pub struct App {
     dispatcher: Arc<Dispatcher>,
@@ -30,8 +30,8 @@ impl platform::App for App {
 
     fn activate(&self, ignoring_other_apps: bool) {
         unsafe {
-            let app: id = msg_send![class!(NSApplication), sharedApplication];
-            let _: () = msg_send![app, activateIgnoringOtherApps: ignoring_other_apps.to_objc()];
+            let app = NSApplication::sharedApplication(nil);
+            app.activateIgnoringOtherApps_(ignoring_other_apps.to_objc());
         }
     }
 
@@ -43,10 +43,48 @@ impl platform::App for App {
         Ok(Box::new(Window::open(options, executor, self.fonts())?))
     }
 
+    fn prompt_for_paths(
+        &self,
+        options: platform::PathPromptOptions,
+    ) -> Option<Vec<std::path::PathBuf>> {
+        unsafe {
+            let panel = NSOpenPanel::openPanel(nil);
+            panel.setCanChooseDirectories_(options.directories.to_objc());
+            panel.setCanChooseFiles_(options.files.to_objc());
+            panel.setAllowsMultipleSelection_(options.multiple.to_objc());
+            panel.setResolvesAliases_(false.to_objc());
+            let response = panel.runModal();
+            if response == NSModalResponse::NSModalResponseOk {
+                let mut result = Vec::new();
+                let urls = panel.URLs();
+                for i in 0..urls.count() {
+                    let url = urls.objectAtIndex(i);
+                    let string = url.absoluteString();
+                    let string = std::ffi::CStr::from_ptr(string.UTF8String())
+                        .to_string_lossy()
+                        .to_string();
+                    if let Some(path) = string.strip_prefix("file://") {
+                        result.push(PathBuf::from(path));
+                    }
+                }
+                Some(result)
+            } else {
+                None
+            }
+        }
+    }
+
     fn fonts(&self) -> Arc<dyn platform::FontSystem> {
         self.fonts.clone()
     }
 
+    fn quit(&self) {
+        unsafe {
+            let app = NSApplication::sharedApplication(nil);
+            let _: () = msg_send![app, terminate: nil];
+        }
+    }
+
     fn copy(&self, text: &str) {
         unsafe {
             let data = NSData::dataWithBytes_length_(

gpui/src/platform/mac/runner.rs πŸ”—

@@ -1,8 +1,11 @@
-use crate::platform::Event;
+use crate::{keymap::Keystroke, platform::Event, Menu, MenuItem};
 use cocoa::{
-    appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
-    base::{id, nil},
-    foundation::{NSArray, NSAutoreleasePool, NSString},
+    appkit::{
+        NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
+        NSEventModifierFlags, NSMenu, NSMenuItem, NSWindow,
+    },
+    base::{id, nil, selector},
+    foundation::{NSArray, NSAutoreleasePool, NSInteger, NSString},
 };
 use ctor::ctor;
 use objc::{
@@ -50,6 +53,10 @@ unsafe fn build_classes() {
             sel!(applicationDidResignActive:),
             did_resign_active as extern "C" fn(&mut Object, Sel, id),
         );
+        decl.add_method(
+            sel!(handleGPUIMenuItem:),
+            handle_menu_item as extern "C" fn(&mut Object, Sel, id),
+        );
         decl.add_method(
             sel!(application:openFiles:),
             open_files as extern "C" fn(&mut Object, Sel, id, id),
@@ -65,12 +72,89 @@ pub struct Runner {
     resign_active_callback: Option<Box<dyn FnMut()>>,
     event_callback: Option<Box<dyn FnMut(Event) -> bool>>,
     open_files_callback: Option<Box<dyn FnMut(Vec<PathBuf>)>>,
+    menu_command_callback: Option<Box<dyn FnMut(&str)>>,
+    menu_item_actions: Vec<String>,
 }
 
 impl Runner {
     pub fn new() -> Self {
         Default::default()
     }
+
+    unsafe fn create_menu_bar(&mut self, menus: &[Menu]) -> id {
+        let menu_bar = NSMenu::new(nil).autorelease();
+        self.menu_item_actions.clear();
+
+        for menu_config in menus {
+            let menu_bar_item = NSMenuItem::new(nil).autorelease();
+            let menu = NSMenu::new(nil).autorelease();
+
+            menu.setTitle_(ns_string(menu_config.name));
+
+            for item_config in menu_config.items {
+                let item;
+
+                match item_config {
+                    MenuItem::Separator => {
+                        item = NSMenuItem::separatorItem(nil);
+                    }
+                    MenuItem::Action {
+                        name,
+                        keystroke,
+                        action,
+                    } => {
+                        if let Some(keystroke) = keystroke {
+                            let keystroke = Keystroke::parse(keystroke).unwrap_or_else(|err| {
+                                panic!(
+                                    "Invalid keystroke for menu item {}:{} - {:?}",
+                                    menu_config.name, name, err
+                                )
+                            });
+
+                            let mut mask = NSEventModifierFlags::empty();
+                            for (modifier, flag) in &[
+                                (keystroke.cmd, NSEventModifierFlags::NSCommandKeyMask),
+                                (keystroke.ctrl, NSEventModifierFlags::NSControlKeyMask),
+                                (keystroke.alt, NSEventModifierFlags::NSAlternateKeyMask),
+                            ] {
+                                if *modifier {
+                                    mask |= *flag;
+                                }
+                            }
+
+                            item = NSMenuItem::alloc(nil)
+                                .initWithTitle_action_keyEquivalent_(
+                                    ns_string(name),
+                                    selector("handleGPUIMenuItem:"),
+                                    ns_string(&keystroke.key),
+                                )
+                                .autorelease();
+                            item.setKeyEquivalentModifierMask_(mask);
+                        } else {
+                            item = NSMenuItem::alloc(nil)
+                                .initWithTitle_action_keyEquivalent_(
+                                    ns_string(name),
+                                    selector("handleGPUIMenuItem:"),
+                                    ns_string(""),
+                                )
+                                .autorelease();
+                        }
+
+                        let tag = self.menu_item_actions.len() as NSInteger;
+                        let _: () = msg_send![item, setTag: tag];
+                        self.menu_item_actions.push(action.to_string());
+                    }
+                }
+
+                menu.addItem_(item);
+            }
+
+            menu_bar_item.setSubmenu_(menu);
+            menu_bar.addItem_(menu_bar_item);
+        }
+
+        menu_bar
+    }
 }
 
 impl crate::platform::Runner for Runner {
@@ -79,6 +163,11 @@ impl crate::platform::Runner for Runner {
         self
     }
 
+    fn on_menu_command<F: 'static + FnMut(&str)>(mut self, callback: F) -> Self {
+        self.menu_command_callback = Some(Box::new(callback));
+        self
+    }
+
     fn on_become_active<F: 'static + FnMut()>(mut self, callback: F) -> Self {
         log::info!("become active");
         self.become_active_callback = Some(Box::new(callback));
@@ -100,22 +189,28 @@ impl crate::platform::Runner for Runner {
         self
     }
 
+    fn set_menus(mut self, menus: &[Menu]) -> Self {
+        unsafe {
+            let app: id = msg_send![APP_CLASS, sharedApplication];
+            app.setMainMenu_(self.create_menu_bar(menus));
+        }
+        self
+    }
+
     fn run(self) {
         unsafe {
             let self_ptr = Box::into_raw(Box::new(self));
 
             let pool = NSAutoreleasePool::new(nil);
             let app: id = msg_send![APP_CLASS, sharedApplication];
-            let _: () = msg_send![
-                app,
-                setActivationPolicy: NSApplicationActivationPolicyRegular
-            ];
-            (*app).set_ivar(RUNNER_IVAR, self_ptr as *mut c_void);
             let app_delegate: id = msg_send![APP_DELEGATE_CLASS, new];
+
+            (*app).set_ivar(RUNNER_IVAR, self_ptr as *mut c_void);
             (*app_delegate).set_ivar(RUNNER_IVAR, self_ptr as *mut c_void);
-            let _: () = msg_send![app, setDelegate: app_delegate];
-            let _: () = msg_send![app, run];
-            let _: () = msg_send![pool, drain];
+            app.setDelegate_(app_delegate);
+            app.run();
+            pool.drain();
+
             // The Runner is done running when we get here, so we can reinstantiate the Box and drop it.
             Box::from_raw(self_ptr);
         }
@@ -145,9 +240,14 @@ extern "C" fn send_event(this: &mut Object, _sel: Sel, native_event: id) {
 }
 
 extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) {
-    let runner = unsafe { get_runner(this) };
-    if let Some(callback) = runner.finish_launching_callback.take() {
-        callback();
+    unsafe {
+        let app: id = msg_send![APP_CLASS, sharedApplication];
+        app.setActivationPolicy_(NSApplicationActivationPolicyRegular);
+
+        let runner = get_runner(this);
+        if let Some(callback) = runner.finish_launching_callback.take() {
+            callback();
+        }
     }
 }
 
@@ -186,3 +286,20 @@ extern "C" fn open_files(this: &mut Object, _: Sel, _: id, paths: id) {
         callback(paths);
     }
 }
+
+extern "C" fn handle_menu_item(this: &mut Object, _: Sel, item: id) {
+    unsafe {
+        let runner = get_runner(this);
+        if let Some(callback) = runner.menu_command_callback.as_mut() {
+            let tag: NSInteger = msg_send![item, tag];
+            let index = tag as usize;
+            if let Some(action) = runner.menu_item_actions.get(index) {
+                callback(&action);
+            }
+        }
+    }
+}
+
+unsafe fn ns_string(string: &str) -> id {
+    NSString::alloc(nil).init_str(string).autorelease()
+}

gpui/src/platform/mod.rs πŸ”—

@@ -15,7 +15,7 @@ use crate::{
         vector::Vector2F,
     },
     text_layout::Line,
-    Scene,
+    Menu, Scene,
 };
 use anyhow::Result;
 use async_task::Runnable;
@@ -23,11 +23,13 @@ pub use event::Event;
 use std::{ops::Range, path::PathBuf, rc::Rc, sync::Arc};
 
 pub trait Runner {
-    fn on_finish_launching<F: 'static + FnOnce()>(self, callback: F) -> Self where;
+    fn on_finish_launching<F: 'static + FnOnce()>(self, callback: F) -> Self;
+    fn on_menu_command<F: 'static + FnMut(&str)>(self, callback: F) -> Self;
     fn on_become_active<F: 'static + FnMut()>(self, callback: F) -> Self;
     fn on_resign_active<F: 'static + FnMut()>(self, callback: F) -> Self;
     fn on_event<F: 'static + FnMut(Event) -> bool>(self, callback: F) -> Self;
     fn on_open_files<F: 'static + FnMut(Vec<PathBuf>)>(self, callback: F) -> Self;
+    fn set_menus(self, menus: &[Menu]) -> Self;
     fn run(self);
 }
 
@@ -39,7 +41,9 @@ pub trait App {
         options: WindowOptions,
         executor: Rc<executor::Foreground>,
     ) -> Result<Box<dyn Window>>;
+    fn prompt_for_paths(&self, options: PathPromptOptions) -> Option<Vec<PathBuf>>;
     fn fonts(&self) -> Arc<dyn FontSystem>;
+    fn quit(&self);
     fn copy(&self, text: &str);
 }
 
@@ -64,6 +68,12 @@ pub struct WindowOptions<'a> {
     pub title: Option<&'a str>,
 }
 
+pub struct PathPromptOptions {
+    pub files: bool,
+    pub directories: bool,
+    pub multiple: bool,
+}
+
 pub trait FontSystem: Send + Sync {
     fn load_family(&self, name: &str) -> anyhow::Result<Vec<FontId>>;
     fn select_font(

gpui/src/platform/test.rs πŸ”—

@@ -47,6 +47,12 @@ impl super::App for App {
         self.fonts.clone()
     }
 
+    fn quit(&self) {}
+
+    fn prompt_for_paths(&self, _: super::PathPromptOptions) -> Option<Vec<std::path::PathBuf>> {
+        None
+    }
+
     fn copy(&self, _: &str) {}
 }
 

zed/src/lib.rs πŸ”—

@@ -1,6 +1,7 @@
 pub mod assets;
 pub mod editor;
 pub mod file_finder;
+pub mod menus;
 mod operation_queue;
 pub mod settings;
 mod sum_tree;

zed/src/main.rs πŸ”—

@@ -1,10 +1,10 @@
 use fs::OpenOptions;
-use gpui::platform::{current as platform, Runner as _};
+use gpui::platform::{current as platform, PathPromptOptions, Runner as _};
 use log::LevelFilter;
 use simplelog::SimpleLogger;
 use std::{fs, path::PathBuf};
 use zed::{
-    assets, editor, file_finder, settings,
+    assets, editor, file_finder, menus, settings,
     workspace::{self, OpenParams},
 };
 
@@ -14,10 +14,33 @@ fn main() {
     let app = gpui::App::new(assets::Assets).unwrap();
     let (_, settings_rx) = settings::channel(&app.font_cache()).unwrap();
 
-    {
-        let mut app = app.clone();
-        platform::runner()
-            .on_finish_launching(move || {
+    platform::runner()
+        .set_menus(menus::MENUS)
+        .on_menu_command({
+            let app = app.clone();
+            let settings_rx = settings_rx.clone();
+            move |command| match command {
+                "app:open" => {
+                    if let Some(paths) = app.platform().prompt_for_paths(PathPromptOptions {
+                        files: true,
+                        directories: true,
+                        multiple: true,
+                    }) {
+                        app.dispatch_global_action(
+                            "workspace:open_paths",
+                            OpenParams {
+                                paths,
+                                settings: settings_rx.clone(),
+                            },
+                        );
+                    }
+                }
+                _ => app.dispatch_global_action(command, ()),
+            }
+        })
+        .on_finish_launching({
+            let mut app = app.clone();
+            move || {
                 workspace::init(&mut app);
                 editor::init(&mut app);
                 file_finder::init(&mut app);
@@ -36,9 +59,9 @@ fn main() {
                         },
                     );
                 }
-            })
-            .run();
-    }
+            }
+        })
+        .run();
 }
 
 fn init_logger() {

zed/src/menus.rs πŸ”—

@@ -0,0 +1,60 @@
+use gpui::{Menu, MenuItem};
+
+#[cfg(target_os = "macos")]
+pub const MENUS: &'static [Menu] = &[
+    Menu {
+        name: "Zed",
+        items: &[
+            MenuItem::Action {
+                name: "About Zed…",
+                keystroke: None,
+                action: "app:about-zed",
+            },
+            MenuItem::Separator,
+            MenuItem::Action {
+                name: "Quit",
+                keystroke: Some("cmd-q"),
+                action: "app:quit",
+            },
+        ],
+    },
+    Menu {
+        name: "File",
+        items: &[MenuItem::Action {
+            name: "Open…",
+            keystroke: Some("cmd-o"),
+            action: "app:open",
+        }],
+    },
+    Menu {
+        name: "Edit",
+        items: &[
+            MenuItem::Action {
+                name: "Undo",
+                keystroke: Some("cmd-z"),
+                action: "editor:undo",
+            },
+            MenuItem::Action {
+                name: "Redo",
+                keystroke: Some("cmd-Z"),
+                action: "editor:redo",
+            },
+            MenuItem::Separator,
+            MenuItem::Action {
+                name: "Cut",
+                keystroke: Some("cmd-x"),
+                action: "editor:cut",
+            },
+            MenuItem::Action {
+                name: "Copy",
+                keystroke: Some("cmd-c"),
+                action: "editor:copy",
+            },
+            MenuItem::Action {
+                name: "Paste",
+                keystroke: Some("cmd-v"),
+                action: "editor:paste",
+            },
+        ],
+    },
+];

zed/src/workspace/mod.rs πŸ”—

@@ -14,6 +14,7 @@ use std::path::PathBuf;
 
 pub fn init(app: &mut App) {
     app.add_global_action("workspace:open_paths", open_paths);
+    app.add_global_action("app:quit", quit);
     pane::init(app);
     workspace_view::init(app);
 }
@@ -50,6 +51,10 @@ fn open_paths(params: &OpenParams, app: &mut MutableAppContext) {
     app.add_window(|ctx| WorkspaceView::new(workspace, params.settings.clone(), ctx));
 }
 
+fn quit(_: &(), app: &mut MutableAppContext) {
+    app.platform().quit();
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;