1use 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
28pub struct Menu {
30 trigger: SharedString,
31 entries: Vec<Entry>,
32 open: bool,
33 focus: FocusHandle,
34 size: Size,
35 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 fn item_indices(&self) -> Vec<usize> {
53 self
54 .entries
55 .iter()
56 .enumerate()
57 .filter(|(_, e)| matches!(e, Entry::Item { .. }))
58 .map(|(i, _)| i)
59 .collect()
60 }
61
62 fn move_highlight(&mut self, delta: isize) {
63 let items = self.item_indices();
64 if items.is_empty() {
65 return;
66 }
67 let pos = items.iter().position(|&i| i == self.highlight).unwrap_or(0);
68 let len = items.len() as isize;
69 let next = (((pos as isize + delta) % len) + len) % len;
70 self.highlight = items[next as usize];
71 }
72
73 fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
74 if !self.open {
75 return;
76 }
77 match event.keystroke.key.as_str() {
78 "escape" => self.open = false,
79 "down" => self.move_highlight(1),
80 "up" => self.move_highlight(-1),
81 "enter" => {
82 self.open = false;
83 if let Some(Entry::Item {
84 handler: Some(handler),
85 ..
86 }) = self.entries.get(self.highlight)
87 {
88 handler(_window, cx);
89 }
90 }
91 _ => return,
92 }
93 cx.notify();
94 cx.stop_propagation();
95 }
96
97 pub fn size(mut self, size: Size) -> Self {
98 self.size = size;
99 self
100 }
101
102 pub fn item(
104 mut self,
105 label: impl Into<SharedString>,
106 handler: impl Fn(&mut Window, &mut App) + 'static,
107 ) -> Self {
108 self.entries.push(Entry::Item {
109 label: label.into(),
110 danger: false,
111 handler: Some(Box::new(handler)),
112 });
113 self
114 }
115
116 pub fn danger_item(
118 mut self,
119 label: impl Into<SharedString>,
120 handler: impl Fn(&mut Window, &mut App) + 'static,
121 ) -> Self {
122 self.entries.push(Entry::Item {
123 label: label.into(),
124 danger: true,
125 handler: Some(Box::new(handler)),
126 });
127 self
128 }
129
130 pub fn section(mut self, label: impl Into<SharedString>) -> Self {
132 self.entries.push(Entry::Section(label.into()));
133 self
134 }
135
136 pub fn divider(mut self) -> Self {
138 self.entries.push(Entry::Divider);
139 self
140 }
141}
142
143impl Render for Menu {
144 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
145 let t = theme(cx);
146 let (height, pad_x, font) = control_metrics(self.size);
147 let radius = t.radius(t.default_radius);
148 let s = surface(t, ColorName::Gray, Variant::Default);
149 let surface_color = t.surface().hsla();
150 let surface_hover = t.surface_hover().hsla();
151 let border = t.border().hsla();
152 let text = t.text().hsla();
153 let dimmed = t.dimmed().hsla();
154 let danger = t
155 .color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 6 })
156 .hsla();
157 let font_xs = t.font_size(Size::Xs);
158 let trigger_hover = s.bg_hover;
159
160 let mut trigger = div()
161 .id("guise-menu-trigger")
162 .track_focus(&self.focus)
163 .flex()
164 .items_center()
165 .gap(px(6.0))
166 .h(px(height))
167 .px(px(pad_x))
168 .rounded(px(radius))
169 .bg(s.bg)
170 .text_color(s.fg)
171 .text_size(px(font))
172 .hover(move |st| st.bg(trigger_hover))
173 .child(self.trigger.clone())
174 .child(
175 div()
176 .text_color(dimmed)
177 .child(SharedString::new_static("\u{25be}")),
178 )
179 .on_key_down(cx.listener(Self::on_key))
180 .on_click(cx.listener(|this, _ev, window, cx| {
181 this.open = !this.open;
182 if this.open {
183 this.highlight = this.item_indices().first().copied().unwrap_or(0);
184 window.focus(&this.focus);
185 }
186 cx.notify();
187 }));
188 if let Some(b) = s.border {
189 trigger = trigger.border_1().border_color(b);
190 }
191
192 let mut wrap = div().relative().child(trigger);
193
194 if self.open {
195 let mut menu = div()
196 .absolute()
197 .top(px(height + 6.0))
198 .left(px(0.0))
199 .min_w(px(180.0))
200 .flex()
201 .flex_col()
202 .gap(px(2.0))
203 .p(px(4.0))
204 .rounded(px(radius))
205 .border_1()
206 .border_color(border)
207 .bg(surface_color)
208 .shadow_md();
209
210 for (i, entry) in self.entries.iter().enumerate() {
211 match entry {
212 Entry::Item {
213 label,
214 danger: is_danger,
215 ..
216 } => {
217 let mut item = div()
218 .id(("guise-menu-item", i))
219 .px(px(10.0))
220 .py(px(6.0))
221 .rounded(px(4.0))
222 .text_size(px(font))
223 .text_color(if *is_danger { danger } else { text })
224 .hover(move |s| s.bg(surface_hover))
225 .child(label.clone());
226 if i == self.highlight {
227 item = item.bg(surface_hover);
228 }
229 menu = 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}