diff --git a/Cargo.lock b/Cargo.lock index e8b571add8..a103488028 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -980,6 +980,7 @@ version = "0.1.0" dependencies = [ "gpui", "settings", + "smallvec", "theme", ] diff --git a/crates/context_menu/Cargo.toml b/crates/context_menu/Cargo.toml index c33b935c45..65f7f59a14 100644 --- a/crates/context_menu/Cargo.toml +++ b/crates/context_menu/Cargo.toml @@ -11,3 +11,4 @@ doctest = false gpui = { path = "../gpui" } settings = { path = "../settings" } theme = { path = "../theme" } +smallvec = "1.6" diff --git a/crates/context_menu/src/context_menu.rs b/crates/context_menu/src/context_menu.rs index 0bcc97a25d..4e5f15aae5 100644 --- a/crates/context_menu/src/context_menu.rs +++ b/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, - widest_item_index: usize, selected_index: Option, visible: bool, } @@ -36,28 +48,22 @@ impl View for ContextMenu { return Empty::new().boxed(); } - let style = cx.global::().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::(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::(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) { @@ -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( + &mut self, + expanded: bool, + cx: &mut RenderContext, + ) -> impl Element { + let style = cx.global::().theme.context_menu.clone(); + Flex::column() + .with_children( + (0..self.items.len()) + .map(|ix| self.render_menu_item::(ix, expanded, cx, &style)), + ) + .contained() + .with_style(style.container) + } + fn render_menu_item( &self, ix: usize, + expanded: bool, cx: &mut RenderContext, 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() + } } } } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 2d93e46c05..d11940b2c6 100644 --- a/crates/gpui/src/app.rs +++ b/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> { - 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> { for view_id in dispatch_path.iter().rev() { let view = self .cx diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index 55c7bf22fe..231339d9e0 100644 --- a/crates/gpui/src/elements.rs +++ b/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::{ diff --git a/crates/gpui/src/elements/keystroke_label.rs b/crates/gpui/src/elements/keystroke_label.rs new file mode 100644 index 0000000000..0112b54846 --- /dev/null +++ b/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, + container_style: ContainerStyle, + text_style: TextStyle, +} + +impl KeystrokeLabel { + pub fn new( + action: Box, + 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) + }) + } +} diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index dca752ed6f..87b0287dc4 100644 --- a/crates/gpui/src/keymap.rs +++ b/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 { diff --git a/crates/gpui/src/presenter.rs b/crates/gpui/src/presenter.rs index 053b69269c..2c9c4719d6 100644 --- a/crates/gpui/src/presenter.rs +++ b/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, parents: &'a mut HashMap, view_stack: Vec, @@ -281,6 +285,14 @@ impl<'a> LayoutContext<'a> { self.view_stack.pop(); size } + + pub(crate) fn keystrokes_for_action( + &self, + action: &dyn Action, + ) -> Option> { + self.app + .keystrokes_for_action(self.window_id, &self.view_stack, action) + } } impl<'a> Deref for LayoutContext<'a> { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index e310459cf7..7d49a2d07d 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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, ); diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 4cbe60db3c..2edd3cef45 100644 --- a/crates/theme/src/theme.rs +++ b/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)] diff --git a/styles/src/styleTree/contextMenu.ts b/styles/src/styleTree/contextMenu.ts index 5458ceda69..e2b60f2685 100644 --- a/styles/src/styleTree/contextMenu.ts +++ b/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" - } + }, } } \ No newline at end of file