Register actions statically / globally (#3264)

Nathan Sobo created

This updates our approach to action registration to make it
static/global.

There are 3 different approaches to creating an action, depending on the
complexity of your action's implementation. All of them involve defining
a data type with the correct trait implementations and registering it,
each a bit more powerful / verbose.

* Define a simple list of unit structs that implement `Action` -
`actions!(Foo, Bar, Baz)`
* Make a more complex data type into an action with `#[action]`. This
derives all the necessary traits and registers the action.
  ```rs
  #[action]
  struct MoveLeft {
    word: true
  }
  ```
* Implement all traits yourself and just register the action with
`#[register_action]`.

Release Notes:

N/A

Change summary

crates/client2/src/client2.rs              |   3 
crates/editor2/src/editor.rs               |   7 
crates/go_to_line2/src/go_to_line.rs       |   1 
crates/gpui2/src/action.rs                 | 134 ++++++++++++++++++-----
crates/gpui2/src/app.rs                    |  31 -----
crates/gpui2/src/gpui2.rs                  |   1 
crates/gpui2_macros/src/action.rs          |  54 +++++++++
crates/gpui2_macros/src/gpui2_macros.rs    |  16 ++
crates/gpui2_macros/src/register_action.rs |  32 +++++
crates/settings2/src/keymap_file.rs        |   4 
crates/storybook2/src/stories/focus.rs     |   2 
crates/zed2/src/languages/json.rs          |   2 
12 files changed, 215 insertions(+), 72 deletions(-)

Detailed changes

crates/client2/src/client2.rs 🔗

@@ -80,7 +80,6 @@ pub fn init(client: &Arc<Client>, cx: &mut AppContext) {
     init_settings(cx);
 
     let client = Arc::downgrade(client);
-    cx.register_action_type::<SignIn>();
     cx.on_action({
         let client = client.clone();
         move |_: &SignIn, cx| {
@@ -93,7 +92,6 @@ pub fn init(client: &Arc<Client>, cx: &mut AppContext) {
         }
     });
 
-    cx.register_action_type::<SignOut>();
     cx.on_action({
         let client = client.clone();
         move |_: &SignOut, cx| {
@@ -106,7 +104,6 @@ pub fn init(client: &Arc<Client>, cx: &mut AppContext) {
         }
     });
 
-    cx.register_action_type::<Reconnect>();
     cx.on_action({
         let client = client.clone();
         move |_: &Reconnect, cx| {

crates/editor2/src/editor.rs 🔗

@@ -448,13 +448,12 @@ pub fn init(cx: &mut AppContext) {
     // cx.register_action_type(Editor::paste);
     // cx.register_action_type(Editor::undo);
     // cx.register_action_type(Editor::redo);
-    cx.register_action_type::<MoveUp>();
     // cx.register_action_type(Editor::move_page_up);
-    cx.register_action_type::<MoveDown>();
+    // cx.register_action_type::<MoveDown>();
     // cx.register_action_type(Editor::move_page_down);
     // cx.register_action_type(Editor::next_screen);
-    cx.register_action_type::<MoveLeft>();
-    cx.register_action_type::<MoveRight>();
+    // cx.register_action_type::<MoveLeft>();
+    // cx.register_action_type::<MoveRight>();
     // cx.register_action_type(Editor::move_to_previous_word_start);
     // cx.register_action_type(Editor::move_to_previous_subword_start);
     // cx.register_action_type(Editor::move_to_next_word_end);

crates/go_to_line2/src/go_to_line.rs 🔗

@@ -4,7 +4,6 @@ use workspace::ModalRegistry;
 actions!(Toggle);
 
 pub fn init(cx: &mut AppContext) {
-    cx.register_action_type::<Toggle>();
     cx.global_mut::<ModalRegistry>()
         .register_modal(Toggle, |_, cx| {
             // if let Some(editor) = workspace

crates/gpui2/src/action.rs 🔗

@@ -1,9 +1,54 @@
 use crate::SharedString;
 use anyhow::{anyhow, Context, Result};
 use collections::{HashMap, HashSet};
+use lazy_static::lazy_static;
+use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard};
 use serde::Deserialize;
 use std::any::{type_name, Any};
 
+/// Actions are used to implement keyboard-driven UI.
+/// When you declare an action, you can bind keys to the action in the keymap and
+/// listeners for that action in the element tree.
+///
+/// To declare a list of simple actions, you can use the actions! macro, which defines a simple unit struct
+/// action for each listed action name.
+/// ```rust
+/// actions!(MoveUp, MoveDown, MoveLeft, MoveRight, Newline);
+/// ```
+/// More complex data types can also be actions. If you annotate your type with the `#[action]` proc macro,
+/// it will automatically
+/// ```
+/// #[action]
+/// pub struct SelectNext {
+///     pub replace_newest: bool,
+/// }
+///
+/// Any type A that satisfies the following bounds is automatically an action:
+///
+/// ```
+/// A: for<'a> Deserialize<'a> + PartialEq + Clone + Default + std::fmt::Debug + 'static,
+/// ```
+///
+/// The `#[action]` annotation will derive these implementations for your struct automatically. If you
+/// want to control them manually, you can use the lower-level `#[register_action]` macro, which only
+/// generates the code needed to register your action before `main`. Then you'll need to implement all
+/// the traits manually.
+///
+/// ```
+/// #[gpui::register_action]
+/// #[derive(gpui::serde::Deserialize, std::cmp::PartialEq, std::clone::Clone, std::fmt::Debug)]
+/// pub struct Paste {
+///     pub content: SharedString,
+/// }
+///
+/// impl std::default::Default for Paste {
+///     fn default() -> Self {
+///         Self {
+///             content: SharedString::from("🍝"),
+///         }
+///     }
+/// }
+/// ```
 pub trait Action: std::fmt::Debug + 'static {
     fn qualified_name() -> SharedString
     where
@@ -17,32 +62,7 @@ pub trait Action: std::fmt::Debug + 'static {
     fn as_any(&self) -> &dyn Any;
 }
 
-// actions defines structs that can be used as actions.
-#[macro_export]
-macro_rules! actions {
-    () => {};
-
-    ( $name:ident ) => {
-        #[derive(::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq, $crate::serde::Deserialize)]
-        pub struct $name;
-    };
-
-    ( $name:ident { $($token:tt)* } ) => {
-        #[derive(::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq, $crate::serde::Deserialize)]
-        pub struct $name { $($token)* }
-    };
-
-    ( $name:ident, $($rest:tt)* ) => {
-        actions!($name);
-        actions!($($rest)*);
-    };
-
-    ( $name:ident { $($token:tt)* }, $($rest:tt)* ) => {
-        actions!($name { $($token)* });
-        actions!($($rest)*);
-    };
-}
-
+// Types become actions by satisfying a list of trait bounds.
 impl<A> Action for A
 where
     A: for<'a> Deserialize<'a> + PartialEq + Clone + Default + std::fmt::Debug + 'static,
@@ -80,6 +100,61 @@ where
     }
 }
 
+type ActionBuilder = fn(json: Option<serde_json::Value>) -> anyhow::Result<Box<dyn Action>>;
+
+lazy_static! {
+    static ref ACTION_REGISTRY: RwLock<ActionRegistry> = RwLock::default();
+}
+
+#[derive(Default)]
+struct ActionRegistry {
+    builders_by_name: HashMap<SharedString, ActionBuilder>,
+    all_names: Vec<SharedString>, // So we can return a static slice.
+}
+
+/// Register an action type to allow it to be referenced in keymaps.
+pub fn register_action<A: Action>() {
+    let name = A::qualified_name();
+    let mut lock = ACTION_REGISTRY.write();
+    lock.builders_by_name.insert(name.clone(), A::build);
+    lock.all_names.push(name);
+}
+
+/// Construct an action based on its name and optional JSON parameters sourced from the keymap.
+pub fn build_action(name: &str, params: Option<serde_json::Value>) -> Result<Box<dyn Action>> {
+    let lock = ACTION_REGISTRY.read();
+    let build_action = lock
+        .builders_by_name
+        .get(name)
+        .ok_or_else(|| anyhow!("no action type registered for {}", name))?;
+    (build_action)(params)
+}
+
+pub fn all_action_names() -> MappedRwLockReadGuard<'static, [SharedString]> {
+    let lock = ACTION_REGISTRY.read();
+    RwLockReadGuard::map(lock, |registry: &ActionRegistry| {
+        registry.all_names.as_slice()
+    })
+}
+
+/// Defines unit structs that can be used as actions.
+/// To use more complex data types as actions, annotate your type with the #[action] macro.
+#[macro_export]
+macro_rules! actions {
+    () => {};
+
+    ( $name:ident ) => {
+        #[gpui::register_action]
+        #[derive(::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq, $crate::serde::Deserialize)]
+        pub struct $name;
+    };
+
+    ( $name:ident, $($rest:tt)* ) => {
+        actions!($name);
+        actions!($($rest)*);
+    };
+}
+
 #[derive(Clone, Debug, Default, Eq, PartialEq)]
 pub struct DispatchContext {
     set: HashSet<SharedString>,
@@ -317,22 +392,23 @@ fn skip_whitespace(source: &str) -> &str {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate as gpui;
     use DispatchContextPredicate::*;
 
     #[test]
     fn test_actions_definition() {
         {
-            actions!(A, B { field: i32 }, C, D, E, F {}, G);
+            actions!(A, B, C, D, E, F, G);
         }
 
         {
             actions!(
                 A,
-                B { field: i32 },
+                B,
                 C,
                 D,
                 E,
-                F {},
+                F,
                 G, // Don't wrap, test the trailing comma
             );
         }

crates/gpui2/src/app.rs 🔗

@@ -17,9 +17,9 @@ use crate::{
     current_platform, image_cache::ImageCache, Action, AnyBox, AnyView, AnyWindowHandle,
     AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context, DispatchPhase, DisplayId,
     Entity, FocusEvent, FocusHandle, FocusId, ForegroundExecutor, KeyBinding, Keymap, LayoutId,
-    PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, Render, SharedString,
-    SubscriberSet, Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement, TextSystem,
-    View, Window, WindowContext, WindowHandle, WindowId,
+    PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, Render, SubscriberSet,
+    Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement, TextSystem, View, Window,
+    WindowContext, WindowHandle, WindowId,
 };
 use anyhow::{anyhow, Result};
 use collections::{HashMap, HashSet, VecDeque};
@@ -140,7 +140,6 @@ impl App {
     }
 }
 
-type ActionBuilder = fn(json: Option<serde_json::Value>) -> anyhow::Result<Box<dyn Action>>;
 pub(crate) type FrameCallback = Box<dyn FnOnce(&mut AppContext)>;
 type Handler = Box<dyn FnMut(&mut AppContext) -> bool + 'static>;
 type Listener = Box<dyn FnMut(&dyn Any, &mut AppContext) -> bool + 'static>;
@@ -176,7 +175,6 @@ pub struct AppContext {
     pub(crate) keymap: Arc<Mutex<Keymap>>,
     pub(crate) global_action_listeners:
         HashMap<TypeId, Vec<Box<dyn Fn(&dyn Action, DispatchPhase, &mut Self)>>>,
-    action_builders: HashMap<SharedString, ActionBuilder>,
     pending_effects: VecDeque<Effect>,
     pub(crate) pending_notifications: HashSet<EntityId>,
     pub(crate) pending_global_notifications: HashSet<TypeId>,
@@ -234,7 +232,6 @@ impl AppContext {
                 windows: SlotMap::with_key(),
                 keymap: Arc::new(Mutex::new(Keymap::default())),
                 global_action_listeners: HashMap::default(),
-                action_builders: HashMap::default(),
                 pending_effects: VecDeque::new(),
                 pending_notifications: HashSet::default(),
                 pending_global_notifications: HashSet::default(),
@@ -695,10 +692,6 @@ impl AppContext {
         )
     }
 
-    pub fn all_action_names<'a>(&'a self) -> impl Iterator<Item = SharedString> + 'a {
-        self.action_builders.keys().cloned()
-    }
-
     /// Move the global of the given type to the stack.
     pub(crate) fn lease_global<G: 'static>(&mut self) -> GlobalLease<G> {
         GlobalLease::new(
@@ -761,24 +754,6 @@ impl AppContext {
             }));
     }
 
-    /// Register an action type to allow it to be referenced in keymaps.
-    pub fn register_action_type<A: Action>(&mut self) {
-        self.action_builders.insert(A::qualified_name(), A::build);
-    }
-
-    /// Construct an action based on its name and parameters.
-    pub fn build_action(
-        &mut self,
-        name: &str,
-        params: Option<serde_json::Value>,
-    ) -> Result<Box<dyn Action>> {
-        let build = self
-            .action_builders
-            .get(name)
-            .ok_or_else(|| anyhow!("no action type registered for {}", name))?;
-        (build)(params)
-    }
-
     /// Event handlers propagate events by default. Call this method to stop dispatching to
     /// event handlers with a lower z-index (mouse) or higher in the tree (keyboard). This is
     /// the opposite of [propagate]. It's also possible to cancel a call to [propagate] by

crates/gpui2/src/gpui2.rs 🔗

@@ -37,6 +37,7 @@ pub use anyhow::Result;
 pub use app::*;
 pub use assets::*;
 pub use color::*;
+pub use ctor::ctor;
 pub use element::*;
 pub use elements::*;
 pub use executor::*;

crates/gpui2_macros/src/action.rs 🔗

@@ -0,0 +1,54 @@
+// Input:
+//
+// #[action]
+// struct Foo {
+//   bar: String,
+// }
+
+// Output:
+//
+// #[gpui::register_action]
+// #[derive(gpui::serde::Deserialize, std::cmp::PartialEq, std::clone::Clone, std::default::Default, std::fmt::Debug)]
+// struct Foo {
+//   bar: String,
+// }
+
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::{parse_macro_input, DeriveInput};
+
+pub fn action(_attr: TokenStream, item: TokenStream) -> TokenStream {
+    let input = parse_macro_input!(item as DeriveInput);
+    let name = &input.ident;
+    let attrs = input
+        .attrs
+        .into_iter()
+        .filter(|attr| !attr.path.is_ident("action"))
+        .collect::<Vec<_>>();
+
+    let attributes = quote! {
+        #[gpui::register_action]
+        #[derive(gpui::serde::Deserialize, std::cmp::PartialEq, std::clone::Clone, std::default::Default, std::fmt::Debug)]
+        #(#attrs)*
+    };
+
+    let output = match input.data {
+        syn::Data::Struct(ref struct_data) => {
+            let fields = &struct_data.fields;
+            quote! {
+                #attributes
+                struct #name { #fields }
+            }
+        }
+        syn::Data::Enum(ref enum_data) => {
+            let variants = &enum_data.variants;
+            quote! {
+                #attributes
+                enum #name { #variants }
+            }
+        }
+        _ => panic!("Expected a struct or an enum."),
+    };
+
+    TokenStream::from(output)
+}

crates/gpui2_macros/src/gpui2_macros.rs 🔗

@@ -1,14 +1,26 @@
-use proc_macro::TokenStream;
-
+mod action;
 mod derive_component;
+mod register_action;
 mod style_helpers;
 mod test;
 
+use proc_macro::TokenStream;
+
 #[proc_macro]
 pub fn style_helpers(args: TokenStream) -> TokenStream {
     style_helpers::style_helpers(args)
 }
 
+#[proc_macro_attribute]
+pub fn action(attr: TokenStream, item: TokenStream) -> TokenStream {
+    action::action(attr, item)
+}
+
+#[proc_macro_attribute]
+pub fn register_action(attr: TokenStream, item: TokenStream) -> TokenStream {
+    register_action::register_action(attr, item)
+}
+
 #[proc_macro_derive(Component, attributes(component))]
 pub fn derive_component(input: TokenStream) -> TokenStream {
     derive_component::derive_component(input)

crates/gpui2_macros/src/register_action.rs 🔗

@@ -0,0 +1,32 @@
+// Input:
+//
+// struct FooBar {}
+
+// Output:
+//
+// struct FooBar {}
+//
+// #[allow(non_snake_case)]
+// #[gpui2::ctor]
+// fn register_foobar_builder() {
+//     gpui2::register_action_builder::<Foo>()
+// }
+use proc_macro::TokenStream;
+use quote::{format_ident, quote};
+use syn::{parse_macro_input, DeriveInput};
+
+pub fn register_action(_attr: TokenStream, item: TokenStream) -> TokenStream {
+    let input = parse_macro_input!(item as DeriveInput);
+    let type_name = &input.ident;
+    let ctor_fn_name = format_ident!("register_{}_builder", type_name);
+
+    let expanded = quote! {
+        #input
+        #[allow(non_snake_case)]
+        #[gpui::ctor]
+        fn #ctor_fn_name() {
+            gpui::register_action::<#type_name>()
+        }
+    };
+    TokenStream::from(expanded)
+}

crates/settings2/src/keymap_file.rs 🔗

@@ -73,9 +73,9 @@ impl KeymapFile {
                                     "Expected first item in array to be a string."
                                 )));
                             };
-                            cx.build_action(&name, Some(data))
+                            gpui::build_action(&name, Some(data))
                         }
-                        Value::String(name) => cx.build_action(&name, None),
+                        Value::String(name) => gpui::build_action(&name, None),
                         Value::Null => Ok(no_action()),
                         _ => {
                             return Some(Err(anyhow!("Expected two-element array, got {action:?}")))

crates/storybook2/src/stories/focus.rs 🔗

@@ -15,8 +15,6 @@ impl FocusStory {
             KeyBinding::new("cmd-a", ActionB, Some("child-1")),
             KeyBinding::new("cmd-c", ActionC, None),
         ]);
-        cx.register_action_type::<ActionA>();
-        cx.register_action_type::<ActionB>();
 
         cx.build_view(move |cx| Self {})
     }

crates/zed2/src/languages/json.rs 🔗

@@ -107,7 +107,7 @@ impl LspAdapter for JsonLspAdapter {
         &self,
         cx: &mut AppContext,
     ) -> BoxFuture<'static, serde_json::Value> {
-        let action_names = cx.all_action_names().collect::<Vec<_>>();
+        let action_names = gpui::all_action_names();
         let staff_mode = cx.is_staff();
         let language_names = &self.languages.language_names();
         let settings_schema = cx.global::<SettingsStore>().json_schema(