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