1use 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
27pub struct Menu {
29 trigger: SharedString,
30 entries: Vec<Entry>,
31 open: bool,
32 focus: FocusHandle,
33 size: Size,
34 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 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 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 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 pub fn section(mut self, label: impl Into<SharedString>) -> Self {
130 self.entries.push(Entry::Section(label.into()));
131 self
132 }
133
134 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}