context_menu.rs

  1use std::{any::TypeId, time::Duration};
  2
  3use gpui::{
  4    elements::*, geometry::vector::Vector2F, keymap, platform::CursorStyle, Action, AppContext,
  5    Axis, Entity, MutableAppContext, RenderContext, SizeConstraint, Subscription, View,
  6    ViewContext,
  7};
  8use menu::*;
  9use settings::Settings;
 10
 11pub fn init(cx: &mut MutableAppContext) {
 12    cx.add_action(ContextMenu::select_first);
 13    cx.add_action(ContextMenu::select_last);
 14    cx.add_action(ContextMenu::select_next);
 15    cx.add_action(ContextMenu::select_prev);
 16    cx.add_action(ContextMenu::confirm);
 17    cx.add_action(ContextMenu::cancel);
 18}
 19
 20pub enum ContextMenuItem {
 21    Item {
 22        label: String,
 23        action: Box<dyn Action>,
 24    },
 25    Separator,
 26}
 27
 28impl ContextMenuItem {
 29    pub fn item(label: impl ToString, action: impl 'static + Action) -> Self {
 30        Self::Item {
 31            label: label.to_string(),
 32            action: Box::new(action),
 33        }
 34    }
 35
 36    pub fn separator() -> Self {
 37        Self::Separator
 38    }
 39
 40    fn is_separator(&self) -> bool {
 41        matches!(self, Self::Separator)
 42    }
 43
 44    fn action_id(&self) -> Option<TypeId> {
 45        match self {
 46            ContextMenuItem::Item { action, .. } => Some(action.id()),
 47            ContextMenuItem::Separator => None,
 48        }
 49    }
 50}
 51
 52pub struct ContextMenu {
 53    position: Vector2F,
 54    items: Vec<ContextMenuItem>,
 55    selected_index: Option<usize>,
 56    visible: bool,
 57    previously_focused_view_id: Option<usize>,
 58    _actions_observation: Subscription,
 59}
 60
 61impl Entity for ContextMenu {
 62    type Event = ();
 63}
 64
 65impl View for ContextMenu {
 66    fn ui_name() -> &'static str {
 67        "ContextMenu"
 68    }
 69
 70    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
 71        let mut cx = Self::default_keymap_context();
 72        cx.set.insert("menu".into());
 73        cx
 74    }
 75
 76    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
 77        if !self.visible {
 78            return Empty::new().boxed();
 79        }
 80
 81        // Render the menu once at minimum width.
 82        let mut collapsed_menu = self.render_menu_for_measurement(cx).boxed();
 83        let expanded_menu = self
 84            .render_menu(cx)
 85            .constrained()
 86            .dynamically(move |constraint, cx| {
 87                SizeConstraint::strict_along(
 88                    Axis::Horizontal,
 89                    collapsed_menu.layout(constraint, cx).x(),
 90                )
 91            })
 92            .boxed();
 93
 94        Overlay::new(expanded_menu)
 95            .with_abs_position(self.position)
 96            .boxed()
 97    }
 98
 99    fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
100        self.reset(cx);
101    }
102}
103
104impl ContextMenu {
105    pub fn new(cx: &mut ViewContext<Self>) -> Self {
106        Self {
107            position: Default::default(),
108            items: Default::default(),
109            selected_index: Default::default(),
110            visible: Default::default(),
111            previously_focused_view_id: Default::default(),
112            _actions_observation: cx.observe_actions(Self::action_dispatched),
113        }
114    }
115
116    fn action_dispatched(&mut self, action_id: TypeId, cx: &mut ViewContext<Self>) {
117        if let Some(ix) = self
118            .items
119            .iter()
120            .position(|item| item.action_id() == Some(action_id))
121        {
122            self.selected_index = Some(ix);
123            cx.notify();
124            cx.spawn(|this, mut cx| async move {
125                cx.background().timer(Duration::from_millis(100)).await;
126                this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx));
127            })
128            .detach();
129        }
130    }
131
132    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
133        if let Some(ix) = self.selected_index {
134            if let Some(ContextMenuItem::Item { action, .. }) = self.items.get(ix) {
135                let window_id = cx.window_id();
136                let view_id = cx.view_id();
137                cx.dispatch_action_at(window_id, view_id, action.as_ref());
138            }
139        }
140    }
141
142    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
143        self.reset(cx);
144        cx.defer(|this, cx| {
145            if cx.handle().is_focused(cx) {
146                let window_id = cx.window_id();
147                (**cx).focus(window_id, this.previously_focused_view_id.take());
148            }
149        });
150    }
151
152    fn reset(&mut self, cx: &mut ViewContext<Self>) {
153        self.items.clear();
154        self.visible = false;
155        self.selected_index.take();
156        cx.notify();
157    }
158
159    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
160        self.selected_index = self.items.iter().position(|item| !item.is_separator());
161        cx.notify();
162    }
163
164    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
165        for (ix, item) in self.items.iter().enumerate().rev() {
166            if !item.is_separator() {
167                self.selected_index = Some(ix);
168                cx.notify();
169                break;
170            }
171        }
172    }
173
174    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
175        if let Some(ix) = self.selected_index {
176            for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
177                if !item.is_separator() {
178                    self.selected_index = Some(ix);
179                    cx.notify();
180                    break;
181                }
182            }
183        } else {
184            self.select_first(&Default::default(), cx);
185        }
186    }
187
188    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
189        if let Some(ix) = self.selected_index {
190            for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
191                if !item.is_separator() {
192                    self.selected_index = Some(ix);
193                    cx.notify();
194                    break;
195                }
196            }
197        } else {
198            self.select_last(&Default::default(), cx);
199        }
200    }
201
202    pub fn show(
203        &mut self,
204        position: Vector2F,
205        items: impl IntoIterator<Item = ContextMenuItem>,
206        cx: &mut ViewContext<Self>,
207    ) {
208        let mut items = items.into_iter().peekable();
209        if items.peek().is_some() {
210            self.items = items.collect();
211            self.position = position;
212            self.visible = true;
213            if !cx.is_self_focused() {
214                self.previously_focused_view_id = cx.focused_view_id(cx.window_id());
215            }
216            cx.focus_self();
217        } else {
218            self.visible = false;
219        }
220        cx.notify();
221    }
222
223    fn render_menu_for_measurement(&self, cx: &mut RenderContext<Self>) -> impl Element {
224        let style = cx.global::<Settings>().theme.context_menu.clone();
225        Flex::row()
226            .with_child(
227                Flex::column()
228                    .with_children(self.items.iter().enumerate().map(|(ix, item)| {
229                        match item {
230                            ContextMenuItem::Item { label, .. } => {
231                                let style = style
232                                    .item
233                                    .style_for(Default::default(), Some(ix) == self.selected_index);
234                                Label::new(label.to_string(), style.label.clone())
235                                    .contained()
236                                    .with_style(style.container)
237                                    .boxed()
238                            }
239                            ContextMenuItem::Separator => Empty::new()
240                                .collapsed()
241                                .contained()
242                                .with_style(style.separator)
243                                .constrained()
244                                .with_height(1.)
245                                .boxed(),
246                        }
247                    }))
248                    .boxed(),
249            )
250            .with_child(
251                Flex::column()
252                    .with_children(self.items.iter().enumerate().map(|(ix, item)| {
253                        match item {
254                            ContextMenuItem::Item { action, .. } => {
255                                let style = style
256                                    .item
257                                    .style_for(Default::default(), Some(ix) == self.selected_index);
258                                KeystrokeLabel::new(
259                                    action.boxed_clone(),
260                                    style.keystroke.container,
261                                    style.keystroke.text.clone(),
262                                )
263                                .boxed()
264                            }
265                            ContextMenuItem::Separator => Empty::new()
266                                .collapsed()
267                                .constrained()
268                                .with_height(1.)
269                                .contained()
270                                .with_style(style.separator)
271                                .boxed(),
272                        }
273                    }))
274                    .boxed(),
275            )
276            .contained()
277            .with_style(style.container)
278    }
279
280    fn render_menu(&self, cx: &mut RenderContext<Self>) -> impl Element {
281        enum Menu {}
282        enum MenuItem {}
283        let style = cx.global::<Settings>().theme.context_menu.clone();
284        MouseEventHandler::new::<Menu, _, _>(0, cx, |_, cx| {
285            Flex::column()
286                .with_children(self.items.iter().enumerate().map(|(ix, item)| {
287                    match item {
288                        ContextMenuItem::Item { label, action } => {
289                            let action = action.boxed_clone();
290                            MouseEventHandler::new::<MenuItem, _, _>(ix, cx, |state, _| {
291                                let style =
292                                    style.item.style_for(state, Some(ix) == self.selected_index);
293                                Flex::row()
294                                    .with_child(
295                                        Label::new(label.to_string(), style.label.clone()).boxed(),
296                                    )
297                                    .with_child({
298                                        KeystrokeLabel::new(
299                                            action.boxed_clone(),
300                                            style.keystroke.container,
301                                            style.keystroke.text.clone(),
302                                        )
303                                        .flex_float()
304                                        .boxed()
305                                    })
306                                    .contained()
307                                    .with_style(style.container)
308                                    .boxed()
309                            })
310                            .with_cursor_style(CursorStyle::PointingHand)
311                            .on_click(move |_, _, cx| {
312                                cx.dispatch_any_action(action.boxed_clone());
313                                cx.dispatch_action(Cancel);
314                            })
315                            .boxed()
316                        }
317                        ContextMenuItem::Separator => Empty::new()
318                            .constrained()
319                            .with_height(1.)
320                            .contained()
321                            .with_style(style.separator)
322                            .boxed(),
323                    }
324                }))
325                .contained()
326                .with_style(style.container)
327                .boxed()
328        })
329        .on_mouse_down_out(|_, cx| cx.dispatch_action(Cancel))
330        .on_right_mouse_down_out(|_, cx| cx.dispatch_action(Cancel))
331    }
332}