WIP

Antonio Scandurra created

Change summary

Cargo.lock                                  |   1 
crates/context_menu/Cargo.toml              |   1 
crates/context_menu/src/context_menu.rs     | 108 ++++++++++++++--------
crates/gpui/src/app.rs                      |  11 +-
crates/gpui/src/elements.rs                 |   5 
crates/gpui/src/elements/keystroke_label.rs |  92 +++++++++++++++++++
crates/gpui/src/keymap.rs                   |  30 ++++++
crates/gpui/src/presenter.rs                |  12 ++
crates/project_panel/src/project_panel.rs   |  20 ---
crates/theme/src/theme.rs                   |   1 
styles/src/styleTree/contextMenu.ts         |   3 
11 files changed, 219 insertions(+), 65 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -980,6 +980,7 @@ version = "0.1.0"
 dependencies = [
  "gpui",
  "settings",
+ "smallvec",
  "theme",
 ]
 

crates/context_menu/Cargo.toml 🔗

@@ -11,3 +11,4 @@ doctest = false
 gpui = { path = "../gpui" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
+smallvec = "1.6"

crates/context_menu/src/context_menu.rs 🔗

@@ -12,10 +12,22 @@ pub enum ContextMenuItem {
     Separator,
 }
 
+impl ContextMenuItem {
+    pub fn item(label: String, action: impl 'static + Action) -> Self {
+        Self::Item {
+            label,
+            action: Box::new(action),
+        }
+    }
+
+    pub fn separator() -> Self {
+        Self::Separator
+    }
+}
+
 pub struct ContextMenu {
     position: Vector2F,
     items: Vec<ContextMenuItem>,
-    widest_item_index: usize,
     selected_index: Option<usize>,
     visible: bool,
 }
@@ -36,28 +48,22 @@ impl View for ContextMenu {
             return Empty::new().boxed();
         }
 
-        let style = cx.global::<Settings>().theme.context_menu.clone();
-
-        let mut widest_item = self.render_menu_item::<()>(self.widest_item_index, cx, &style);
-
-        Overlay::new(
-            Flex::column()
-                .with_children(
-                    (0..self.items.len()).map(|ix| self.render_menu_item::<Tag>(ix, cx, &style)),
+        // Render the menu once at minimum width.
+        let mut collapsed_menu = self.render_menu::<()>(false, cx).boxed();
+        let expanded_menu = self
+            .render_menu::<Tag>(true, cx)
+            .constrained()
+            .dynamically(move |constraint, cx| {
+                SizeConstraint::strict_along(
+                    Axis::Horizontal,
+                    collapsed_menu.layout(constraint, cx).x(),
                 )
-                .constrained()
-                .dynamically(move |constraint, cx| {
-                    SizeConstraint::strict_along(
-                        Axis::Horizontal,
-                        widest_item.layout(constraint, cx).x(),
-                    )
-                })
-                .contained()
-                .with_style(style.container)
-                .boxed(),
-        )
-        .with_abs_position(self.position)
-        .boxed()
+            })
+            .boxed();
+
+        Overlay::new(expanded_menu)
+            .with_abs_position(self.position)
+            .boxed()
     }
 
     fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
@@ -72,7 +78,6 @@ impl ContextMenu {
             position: Default::default(),
             items: Default::default(),
             selected_index: Default::default(),
-            widest_item_index: Default::default(),
             visible: false,
         }
     }
@@ -86,25 +91,31 @@ impl ContextMenu {
         let mut items = items.into_iter().peekable();
         assert!(items.peek().is_some(), "must have at least one item");
         self.items = items.collect();
-        self.widest_item_index = self
-            .items
-            .iter()
-            .enumerate()
-            .max_by_key(|(_, item)| match item {
-                ContextMenuItem::Item { label, .. } => label.chars().count(),
-                ContextMenuItem::Separator => 0,
-            })
-            .unwrap()
-            .0;
         self.position = position;
         self.visible = true;
         cx.focus_self();
         cx.notify();
     }
 
+    fn render_menu<Tag: 'static>(
+        &mut self,
+        expanded: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> impl Element {
+        let style = cx.global::<Settings>().theme.context_menu.clone();
+        Flex::column()
+            .with_children(
+                (0..self.items.len())
+                    .map(|ix| self.render_menu_item::<Tag>(ix, expanded, cx, &style)),
+            )
+            .contained()
+            .with_style(style.container)
+    }
+
     fn render_menu_item<T: 'static>(
         &self,
         ix: usize,
+        expanded: bool,
         cx: &mut RenderContext<ContextMenu>,
         style: &theme::ContextMenu,
     ) -> ElementBox {
@@ -115,18 +126,35 @@ impl ContextMenu {
                     let style = style.item.style_for(state, Some(ix) == self.selected_index);
                     Flex::row()
                         .with_child(Label::new(label.to_string(), style.label.clone()).boxed())
+                        .with_child({
+                            let label = KeystrokeLabel::new(
+                                action.boxed_clone(),
+                                style.keystroke.container,
+                                style.keystroke.text.clone(),
+                            );
+                            if expanded {
+                                label.flex_float().boxed()
+                            } else {
+                                label.boxed()
+                            }
+                        })
                         .boxed()
                 })
                 .on_click(move |_, _, cx| cx.dispatch_any_action(action.boxed_clone()))
                 .boxed()
             }
-            ContextMenuItem::Separator => Empty::new()
-                .contained()
-                .with_style(style.separator)
-                .constrained()
-                .with_height(1.)
-                .flex(1., false)
-                .boxed(),
+            ContextMenuItem::Separator => {
+                let mut separator = Empty::new();
+                if !expanded {
+                    separator = separator.collapsed();
+                }
+                separator
+                    .contained()
+                    .with_style(style.separator)
+                    .constrained()
+                    .with_height(1.)
+                    .boxed()
+            }
         }
     }
 }

crates/gpui/src/app.rs 🔗

@@ -1414,11 +1414,12 @@ impl MutableAppContext {
     }
 
     /// Return keystrokes that would dispatch the given action closest to the focused view, if there are any.
-    pub fn keystrokes_for_action(&self, action: &dyn Action) -> Option<SmallVec<[Keystroke; 2]>> {
-        let window_id = self.cx.platform.key_window_id()?;
-        let (presenter, _) = self.presenters_and_platform_windows.get(&window_id)?;
-        let dispatch_path = presenter.borrow().dispatch_path(&self.cx);
-
+    pub(crate) fn keystrokes_for_action(
+        &self,
+        window_id: usize,
+        dispatch_path: &[usize],
+        action: &dyn Action,
+    ) -> Option<SmallVec<[Keystroke; 2]>> {
         for view_id in dispatch_path.iter().rev() {
             let view = self
                 .cx

crates/gpui/src/elements.rs 🔗

@@ -8,6 +8,7 @@ mod expanded;
 mod flex;
 mod hook;
 mod image;
+mod keystroke_label;
 mod label;
 mod list;
 mod mouse_event_handler;
@@ -20,8 +21,8 @@ mod uniform_list;
 use self::expanded::Expanded;
 pub use self::{
     align::*, canvas::*, constrained_box::*, container::*, empty::*, event_handler::*, flex::*,
-    hook::*, image::*, label::*, list::*, mouse_event_handler::*, overlay::*, stack::*, svg::*,
-    text::*, uniform_list::*,
+    hook::*, image::*, keystroke_label::*, label::*, list::*, mouse_event_handler::*, overlay::*,
+    stack::*, svg::*, text::*, uniform_list::*,
 };
 pub use crate::presenter::ChildView;
 use crate::{

crates/gpui/src/elements/keystroke_label.rs 🔗

@@ -0,0 +1,92 @@
+use crate::{
+    elements::*,
+    fonts::TextStyle,
+    geometry::{rect::RectF, vector::Vector2F},
+    Action, ElementBox, Event, EventContext, LayoutContext, PaintContext, SizeConstraint,
+};
+use serde_json::json;
+
+use super::ContainerStyle;
+
+pub struct KeystrokeLabel {
+    action: Box<dyn Action>,
+    container_style: ContainerStyle,
+    text_style: TextStyle,
+}
+
+impl KeystrokeLabel {
+    pub fn new(
+        action: Box<dyn Action>,
+        container_style: ContainerStyle,
+        text_style: TextStyle,
+    ) -> Self {
+        Self {
+            action,
+            container_style,
+            text_style,
+        }
+    }
+}
+
+impl Element for KeystrokeLabel {
+    type LayoutState = ElementBox;
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: SizeConstraint,
+        cx: &mut LayoutContext,
+    ) -> (Vector2F, ElementBox) {
+        let mut element = if let Some(keystrokes) = cx.keystrokes_for_action(self.action.as_ref()) {
+            Flex::row()
+                .with_children(keystrokes.iter().map(|keystroke| {
+                    Label::new(keystroke.to_string(), self.text_style.clone())
+                        .contained()
+                        .with_style(self.container_style)
+                        .boxed()
+                }))
+                .boxed()
+        } else {
+            Empty::new().collapsed().boxed()
+        };
+
+        let size = element.layout(constraint, cx);
+        (size, element)
+    }
+
+    fn paint(
+        &mut self,
+        bounds: RectF,
+        visible_bounds: RectF,
+        element: &mut ElementBox,
+        cx: &mut PaintContext,
+    ) {
+        element.paint(bounds.origin(), visible_bounds, cx);
+    }
+
+    fn dispatch_event(
+        &mut self,
+        event: &Event,
+        _: RectF,
+        _: RectF,
+        element: &mut ElementBox,
+        _: &mut (),
+        cx: &mut EventContext,
+    ) -> bool {
+        element.dispatch_event(event, cx)
+    }
+
+    fn debug(
+        &self,
+        _: RectF,
+        element: &ElementBox,
+        _: &(),
+        cx: &crate::DebugContext,
+    ) -> serde_json::Value {
+        json!({
+            "type": "KeystrokeLabel",
+            "action": self.action.name(),
+            "child": element.debug(cx)
+        })
+    }
+}

crates/gpui/src/keymap.rs 🔗

@@ -185,7 +185,7 @@ impl Matcher {
                 return Some(binding.keystrokes.clone());
             }
         }
-        todo!()
+        None
     }
 }
 
@@ -311,6 +311,34 @@ impl Keystroke {
     }
 }
 
+impl std::fmt::Display for Keystroke {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if self.ctrl {
+            write!(f, "{}", "^")?;
+        }
+        if self.alt {
+            write!(f, "{}", "⎇")?;
+        }
+        if self.cmd {
+            write!(f, "{}", "⌘")?;
+        }
+        if self.shift {
+            write!(f, "{}", "⇧")?;
+        }
+        let key = match self.key.as_str() {
+            "backspace" => "⌫",
+            "up" => "↑",
+            "down" => "↓",
+            "left" => "←",
+            "right" => "→",
+            "tab" => "⇥",
+            "escape" => "⎋",
+            key => key,
+        };
+        write!(f, "{}", key)
+    }
+}
+
 impl Context {
     pub fn extend(&mut self, other: &Context) {
         for v in &other.set {

crates/gpui/src/presenter.rs 🔗

@@ -4,6 +4,7 @@ use crate::{
     font_cache::FontCache,
     geometry::rect::RectF,
     json::{self, ToJson},
+    keymap::Keystroke,
     platform::{CursorStyle, Event},
     text_layout::TextLayoutCache,
     Action, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AssetCache, ElementBox,
@@ -12,6 +13,7 @@ use crate::{
 };
 use pathfinder_geometry::vector::{vec2f, Vector2F};
 use serde_json::json;
+use smallvec::SmallVec;
 use std::{
     collections::{HashMap, HashSet},
     ops::{Deref, DerefMut},
@@ -148,6 +150,7 @@ impl Presenter {
         cx: &'a mut MutableAppContext,
     ) -> LayoutContext<'a> {
         LayoutContext {
+            window_id: self.window_id,
             rendered_views: &mut self.rendered_views,
             parents: &mut self.parents,
             refreshing,
@@ -257,6 +260,7 @@ pub struct DispatchDirective {
 }
 
 pub struct LayoutContext<'a> {
+    window_id: usize,
     rendered_views: &'a mut HashMap<usize, ElementBox>,
     parents: &'a mut HashMap<usize, usize>,
     view_stack: Vec<usize>,
@@ -281,6 +285,14 @@ impl<'a> LayoutContext<'a> {
         self.view_stack.pop();
         size
     }
+
+    pub(crate) fn keystrokes_for_action(
+        &self,
+        action: &dyn Action,
+    ) -> Option<SmallVec<[Keystroke; 2]>> {
+        self.app
+            .keystrokes_for_action(self.window_id, &self.view_stack, action)
+    }
 }
 
 impl<'a> Deref for LayoutContext<'a> {

crates/project_panel/src/project_panel.rs 🔗

@@ -220,23 +220,11 @@ impl ProjectPanel {
             menu.show(
                 action.position,
                 [
-                    ContextMenuItem::Item {
-                        label: "New File".to_string(),
-                        action: Box::new(AddFile),
-                    },
-                    ContextMenuItem::Item {
-                        label: "New Directory".to_string(),
-                        action: Box::new(AddDirectory),
-                    },
+                    ContextMenuItem::item("New File".to_string(), AddFile),
+                    ContextMenuItem::item("New Directory".to_string(), AddDirectory),
                     ContextMenuItem::Separator,
-                    ContextMenuItem::Item {
-                        label: "Rename".to_string(),
-                        action: Box::new(Rename),
-                    },
-                    ContextMenuItem::Item {
-                        label: "Delete".to_string(),
-                        action: Box::new(Delete),
-                    },
+                    ContextMenuItem::item("Rename".to_string(), Rename),
+                    ContextMenuItem::item("Delete".to_string(), Delete),
                 ],
                 cx,
             );

crates/theme/src/theme.rs 🔗

@@ -253,6 +253,7 @@ pub struct ContextMenuItem {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub label: TextStyle,
+    pub keystroke: ContainedText,
 }
 
 #[derive(Debug, Deserialize, Default)]

styles/src/styleTree/contextMenu.ts 🔗

@@ -15,9 +15,10 @@ export default function contextMenu(theme: Theme) {
     shadow: shadow(theme),
     item: {
       label: text(theme, "sans", "secondary", { size: "sm" }),
+      keystroke: text(theme, "sans", "muted", { size: "sm", weight: "bold" }),
     },
     separator: {
       background: "#00ff00"
-    }
+    },
   }
 }