Skip to main content

guise/overlay/
menu.rs

1//! `Menu` — a stateful dropdown of actions (gpui entity).
2//!
3//! A trigger button toggles a deferred list of items, section labels, and
4//! dividers. Each item carries its own handler, run on click.
5
6use gpui::prelude::*;
7use gpui::{
8    deferred, div, px, App, Context, FocusHandle, IntoElement, KeyDownEvent, SharedString, Window,
9};
10
11use crate::input::control_metrics;
12use crate::style::{surface, Variant};
13use crate::theme::{theme, ColorName, Size};
14
15type ItemHandler = Box<dyn Fn(&mut Window, &mut App) + 'static>;
16
17enum Entry {
18    Item {
19        label: SharedString,
20        danger: bool,
21        handler: Option<ItemHandler>,
22    },
23    Section(SharedString),
24    Divider,
25}
26
27/// A dropdown action menu. Create with `cx.new(|cx| Menu::new(cx, "Actions"))`.
28pub struct Menu {
29    trigger: SharedString,
30    entries: Vec<Entry>,
31    open: bool,
32    focus: FocusHandle,
33    size: Size,
34    /// Entry index of the keyboard-highlighted item.
35    highlight: usize,
36}
37
38impl Menu {
39    pub fn new(cx: &mut Context<Self>, trigger: impl Into<SharedString>) -> Self {
40        Menu {
41            trigger: trigger.into(),
42            entries: Vec::new(),
43            open: false,
44            focus: cx.focus_handle(),
45            size: Size::Sm,
46            highlight: 0,
47        }
48    }
49
50    /// Entry indices that are actionable items (skipping sections/dividers).
51    fn item_indices(&self) -> Vec<usize> {
52        self.entries
53            .iter()
54            .enumerate()
55            .filter(|(_, e)| matches!(e, Entry::Item { .. }))
56            .map(|(i, _)| i)
57            .collect()
58    }
59
60    fn move_highlight(&mut self, delta: isize) {
61        let items = self.item_indices();
62        if items.is_empty() {
63            return;
64        }
65        let pos = items.iter().position(|&i| i == self.highlight).unwrap_or(0);
66        let len = items.len() as isize;
67        let next = (((pos as isize + delta) % len) + len) % len;
68        self.highlight = items[next as usize];
69    }
70
71    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
72        if !self.open {
73            return;
74        }
75        match event.keystroke.key.as_str() {
76            "escape" => self.open = false,
77            "down" => self.move_highlight(1),
78            "up" => self.move_highlight(-1),
79            "enter" => {
80                self.open = false;
81                if let Some(Entry::Item {
82                    handler: Some(handler),
83                    ..
84                }) = self.entries.get(self.highlight)
85                {
86                    handler(_window, cx);
87                }
88            }
89            _ => return,
90        }
91        cx.notify();
92        cx.stop_propagation();
93    }
94
95    pub fn size(mut self, size: Size) -> Self {
96        self.size = size;
97        self
98    }
99
100    /// Add an action item.
101    pub fn item(
102        mut self,
103        label: impl Into<SharedString>,
104        handler: impl Fn(&mut Window, &mut App) + 'static,
105    ) -> Self {
106        self.entries.push(Entry::Item {
107            label: label.into(),
108            danger: false,
109            handler: Some(Box::new(handler)),
110        });
111        self
112    }
113
114    /// Add a destructive action item (rendered in red).
115    pub fn danger_item(
116        mut self,
117        label: impl Into<SharedString>,
118        handler: impl Fn(&mut Window, &mut App) + 'static,
119    ) -> Self {
120        self.entries.push(Entry::Item {
121            label: label.into(),
122            danger: true,
123            handler: Some(Box::new(handler)),
124        });
125        self
126    }
127
128    /// Add a non-interactive section label.
129    pub fn section(mut self, label: impl Into<SharedString>) -> Self {
130        self.entries.push(Entry::Section(label.into()));
131        self
132    }
133
134    /// Add a separating divider.
135    pub fn divider(mut self) -> Self {
136        self.entries.push(Entry::Divider);
137        self
138    }
139}
140
141impl Render for Menu {
142    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
143        let t = theme(cx);
144        let (height, pad_x, font) = control_metrics(self.size);
145        let radius = t.radius(t.default_radius);
146        let s = surface(t, ColorName::Gray, Variant::Default);
147        let surface_color = t.surface().hsla();
148        let surface_hover = t.surface_hover().hsla();
149        let border = t.border().hsla();
150        let text = t.text().hsla();
151        let dimmed = t.dimmed().hsla();
152        let danger = t
153            .color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 6 })
154            .hsla();
155        let font_xs = t.font_size(Size::Xs);
156        let trigger_hover = s.bg_hover;
157
158        let mut trigger = div()
159            .id("guise-menu-trigger")
160            .track_focus(&self.focus)
161            .flex()
162            .items_center()
163            .gap(px(6.0))
164            .h(px(height))
165            .px(px(pad_x))
166            .rounded(px(radius))
167            .bg(s.bg)
168            .text_color(s.fg)
169            .text_size(px(font))
170            .hover(move |st| st.bg(trigger_hover))
171            .child(self.trigger.clone())
172            .child(
173                div()
174                    .text_color(dimmed)
175                    .child(SharedString::new_static("\u{25be}")),
176            )
177            .on_key_down(cx.listener(Self::on_key))
178            .on_click(cx.listener(|this, _ev, window, cx| {
179                this.open = !this.open;
180                if this.open {
181                    this.highlight = this.item_indices().first().copied().unwrap_or(0);
182                    window.focus(&this.focus);
183                }
184                cx.notify();
185            }));
186        if let Some(b) = s.border {
187            trigger = trigger.border_1().border_color(b);
188        }
189
190        let mut wrap = div().relative().child(trigger);
191
192        if self.open {
193            let mut menu = div()
194                .absolute()
195                .top(px(height + 6.0))
196                .left(px(0.0))
197                .min_w(px(180.0))
198                .flex()
199                .flex_col()
200                .gap(px(2.0))
201                .p(px(4.0))
202                .rounded(px(radius))
203                .border_1()
204                .border_color(border)
205                .bg(surface_color)
206                .shadow_md();
207
208            for (i, entry) in self.entries.iter().enumerate() {
209                match entry {
210                    Entry::Item {
211                        label,
212                        danger: is_danger,
213                        ..
214                    } => {
215                        let mut item = div()
216                            .id(("guise-menu-item", i))
217                            .px(px(10.0))
218                            .py(px(6.0))
219                            .rounded(px(4.0))
220                            .text_size(px(font))
221                            .text_color(if *is_danger { danger } else { text })
222                            .hover(move |s| s.bg(surface_hover))
223                            .child(label.clone());
224                        if i == self.highlight {
225                            item = item.bg(surface_hover);
226                        }
227                        menu =
228                            menu.child(item.on_click(cx.listener(move |this, _ev, window, cx| {
229                                this.open = false;
230                                if let Entry::Item {
231                                    handler: Some(handler),
232                                    ..
233                                } = &this.entries[i]
234                                {
235                                    handler(window, cx);
236                                }
237                                cx.notify();
238                            })));
239                    }
240                    Entry::Section(label) => {
241                        menu = menu.child(
242                            div()
243                                .px(px(10.0))
244                                .pt(px(6.0))
245                                .pb(px(2.0))
246                                .text_size(px(font_xs))
247                                .text_color(dimmed)
248                                .child(label.clone()),
249                        );
250                    }
251                    Entry::Divider => {
252                        menu = menu.child(div().my(px(4.0)).h(px(1.0)).bg(border));
253                    }
254                }
255            }
256
257            wrap = wrap.child(deferred(menu));
258        }
259
260        wrap
261    }
262}