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.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 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 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 pub fn section(mut self, label: impl Into<SharedString>) -> Self {
131 self.entries.push(Entry::Section(label.into()));
132 self
133 }
134
135 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}