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.color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 6 }).hsla();
153 let font_xs = t.font_size(Size::Xs);
154 let trigger_hover = s.bg_hover;
155
156 let mut trigger = div()
157 .id("guise-menu-trigger")
158 .track_focus(&self.focus)
159 .flex()
160 .items_center()
161 .gap(px(6.0))
162 .h(px(height))
163 .px(px(pad_x))
164 .rounded(px(radius))
165 .bg(s.bg)
166 .text_color(s.fg)
167 .text_size(px(font))
168 .hover(move |st| st.bg(trigger_hover))
169 .child(self.trigger.clone())
170 .child(
171 div()
172 .text_color(dimmed)
173 .child(SharedString::new_static("\u{25be}")),
174 )
175 .on_key_down(cx.listener(Self::on_key))
176 .on_click(cx.listener(|this, _ev, window, cx| {
177 this.open = !this.open;
178 if this.open {
179 this.highlight = this.item_indices().first().copied().unwrap_or(0);
180 window.focus(&this.focus);
181 }
182 cx.notify();
183 }));
184 if let Some(b) = s.border {
185 trigger = trigger.border_1().border_color(b);
186 }
187
188 let mut wrap = div().relative().child(trigger);
189
190 if self.open {
191 let mut menu = div()
192 .absolute()
193 .top(px(height + 6.0))
194 .left(px(0.0))
195 .min_w(px(180.0))
196 .flex()
197 .flex_col()
198 .gap(px(2.0))
199 .p(px(4.0))
200 .rounded(px(radius))
201 .border_1()
202 .border_color(border)
203 .bg(surface_color)
204 .shadow_md();
205
206 for (i, entry) in self.entries.iter().enumerate() {
207 match entry {
208 Entry::Item { label, danger: is_danger, .. } => {
209 let mut item = div()
210 .id(("guise-menu-item", i))
211 .px(px(10.0))
212 .py(px(6.0))
213 .rounded(px(4.0))
214 .text_size(px(font))
215 .text_color(if *is_danger { danger } else { text })
216 .hover(move |s| s.bg(surface_hover))
217 .child(label.clone());
218 if i == self.highlight {
219 item = item.bg(surface_hover);
220 }
221 menu = menu.child(item.on_click(cx.listener(
222 move |this, _ev, window, cx| {
223 this.open = false;
224 if let Entry::Item {
225 handler: Some(handler),
226 ..
227 } = &this.entries[i]
228 {
229 handler(window, cx);
230 }
231 cx.notify();
232 },
233 )));
234 }
235 Entry::Section(label) => {
236 menu = menu.child(
237 div()
238 .px(px(10.0))
239 .pt(px(6.0))
240 .pb(px(2.0))
241 .text_size(px(font_xs))
242 .text_color(dimmed)
243 .child(label.clone()),
244 );
245 }
246 Entry::Divider => {
247 menu = menu.child(div().my(px(4.0)).h(px(1.0)).bg(border));
248 }
249 }
250 }
251
252 wrap = wrap.child(deferred(menu));
253 }
254
255 wrap
256 }
257}