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
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  /// Add an action item.
103  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  /// Add a destructive action item (rendered in red).
117  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  /// Add a non-interactive section label.
131  pub fn section(mut self, label: impl Into<SharedString>) -> Self {
132    self.entries.push(Entry::Section(label.into()));
133    self
134  }
135
136  /// Add a separating divider.
137  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}