Skip to repository content420 lines · 15.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:38:31.902Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
dropdown_menu.rs
1use gpui::{Anchor, AnyView, Entity, Pixels, Point, Role};
2
3use crate::{ButtonLike, ContextMenu, PopoverMenu, prelude::*};
4
5use super::PopoverMenuHandle;
6
7#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum DropdownStyle {
9 #[default]
10 Solid,
11 Outlined,
12 Subtle,
13 Ghost,
14}
15
16enum LabelKind {
17 Text(SharedString),
18 Element(AnyElement),
19}
20
21#[derive(IntoElement, RegisterComponent)]
22pub struct DropdownMenu {
23 id: ElementId,
24 label: LabelKind,
25 trigger_size: ButtonSize,
26 trigger_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
27 trigger_icon: Option<IconName>,
28 style: DropdownStyle,
29 menu: Entity<ContextMenu>,
30 full_width: bool,
31 disabled: bool,
32 handle: Option<PopoverMenuHandle<ContextMenu>>,
33 attach: Option<Anchor>,
34 offset: Option<Point<Pixels>>,
35 tab_index: Option<isize>,
36 chevron: bool,
37 aria_label: Option<SharedString>,
38 aria_description: Option<SharedString>,
39 aria_value: Option<SharedString>,
40}
41
42impl DropdownMenu {
43 pub fn new(
44 id: impl Into<ElementId>,
45 label: impl Into<SharedString>,
46 menu: Entity<ContextMenu>,
47 ) -> Self {
48 Self {
49 id: id.into(),
50 label: LabelKind::Text(label.into()),
51 trigger_size: ButtonSize::Default,
52 trigger_tooltip: None,
53 trigger_icon: Some(IconName::ChevronUpDown),
54 style: DropdownStyle::default(),
55 menu,
56 full_width: false,
57 disabled: false,
58 handle: None,
59 attach: None,
60 offset: None,
61 tab_index: None,
62 chevron: true,
63 aria_label: None,
64 aria_description: None,
65 aria_value: None,
66 }
67 }
68
69 pub fn new_with_element(
70 id: impl Into<ElementId>,
71 label: AnyElement,
72 menu: Entity<ContextMenu>,
73 ) -> Self {
74 Self {
75 id: id.into(),
76 label: LabelKind::Element(label),
77 trigger_size: ButtonSize::Default,
78 trigger_tooltip: None,
79 trigger_icon: Some(IconName::ChevronUpDown),
80 style: DropdownStyle::default(),
81 menu,
82 full_width: false,
83 disabled: false,
84 handle: None,
85 attach: None,
86 offset: None,
87 tab_index: None,
88 chevron: true,
89 aria_label: None,
90 aria_description: None,
91 aria_value: None,
92 }
93 }
94
95 pub fn style(mut self, style: DropdownStyle) -> Self {
96 self.style = style;
97 self
98 }
99
100 pub fn trigger_size(mut self, size: ButtonSize) -> Self {
101 self.trigger_size = size;
102 self
103 }
104
105 pub fn trigger_tooltip(
106 mut self,
107 tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
108 ) -> Self {
109 self.trigger_tooltip = Some(Box::new(tooltip));
110 self
111 }
112
113 pub fn trigger_icon(mut self, icon: IconName) -> Self {
114 self.trigger_icon = Some(icon);
115 self
116 }
117
118 pub fn full_width(mut self, full_width: bool) -> Self {
119 self.full_width = full_width;
120 self
121 }
122
123 pub fn handle(mut self, handle: PopoverMenuHandle<ContextMenu>) -> Self {
124 self.handle = Some(handle);
125 self
126 }
127
128 /// Defines which corner of the handle to attach the menu's anchor to.
129 pub fn attach(mut self, attach: Anchor) -> Self {
130 self.attach = Some(attach);
131 self
132 }
133
134 /// Offsets the position of the menu by that many pixels.
135 pub fn offset(mut self, offset: Point<Pixels>) -> Self {
136 self.offset = Some(offset);
137 self
138 }
139
140 pub fn tab_index(mut self, arg: isize) -> Self {
141 self.tab_index = Some(arg);
142 self
143 }
144
145 pub fn no_chevron(mut self) -> Self {
146 self.chevron = false;
147 self
148 }
149
150 /// Sets the label announced by assistive technology.
151 /// Defaults to the trigger's visible label (typically the current value).
152 pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
153 self.aria_label = Some(label.into());
154 self
155 }
156
157 /// Sets the supplementary description announced by assistive technology
158 /// after the combobox's name, role, and value.
159 pub fn aria_description(mut self, description: impl Into<SharedString>) -> Self {
160 self.aria_description = Some(description.into());
161 self
162 }
163
164 /// Sets the current value announced by assistive technology (the selected
165 /// option). Defaults to the trigger's visible text label.
166 pub fn aria_value(mut self, value: impl Into<SharedString>) -> Self {
167 self.aria_value = Some(value.into());
168 self
169 }
170}
171
172impl Disableable for DropdownMenu {
173 fn disabled(mut self, disabled: bool) -> Self {
174 self.disabled = disabled;
175 self
176 }
177}
178
179impl RenderOnce for DropdownMenu {
180 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
181 let button_style = match self.style {
182 DropdownStyle::Solid => ButtonStyle::Filled,
183 DropdownStyle::Subtle => ButtonStyle::Subtle,
184 DropdownStyle::Outlined => ButtonStyle::Outlined,
185 DropdownStyle::Ghost => ButtonStyle::Transparent,
186 };
187
188 let full_width = self.full_width;
189 let trigger_size = self.trigger_size;
190 // Ensure a handle exists so assistive technology can open/close the menu
191 // via the Expand/Collapse accessibility actions (used by UIA on Windows
192 // and AX on macOS; on Linux/AT-SPI the click action is used instead).
193 let handle = self.handle.unwrap_or_default();
194 let expanded = handle.is_deployed();
195
196 // A combobox should announce its current value (the selected option).
197 // Default to the trigger's visible text label when no explicit value
198 // is provided.
199 let aria_value = self.aria_value.clone().or_else(|| match &self.label {
200 LabelKind::Text(text) => Some(text.clone()),
201 LabelKind::Element(_) => None,
202 });
203 let aria_description = self.aria_description.clone();
204
205 let a11y_actions = |button: Button| {
206 let show_handle = handle.clone();
207 let hide_handle = handle.clone();
208 button
209 .on_a11y_action(gpui::accesskit::Action::Expand, move |_, window, cx| {
210 show_handle.show(window, cx);
211 })
212 .on_a11y_action(gpui::accesskit::Action::Collapse, move |_, _window, cx| {
213 hide_handle.hide(cx);
214 })
215 };
216 let a11y_actions_element = |button: ButtonLike| {
217 let show_handle = handle.clone();
218 let hide_handle = handle.clone();
219 button
220 .on_a11y_action(gpui::accesskit::Action::Expand, move |_, window, cx| {
221 show_handle.show(window, cx);
222 })
223 .on_a11y_action(gpui::accesskit::Action::Collapse, move |_, _window, cx| {
224 hide_handle.hide(cx);
225 })
226 };
227
228 let (text_button, element_button) = match self.label {
229 LabelKind::Text(text) => (
230 Some(
231 a11y_actions(Button::new(self.id.clone(), text))
232 .aria_role(Role::ComboBox)
233 .when_some(self.aria_label, |this, label| this.aria_label(label))
234 .when_some(aria_description, |this, description| {
235 this.aria_description(description)
236 })
237 .when_some(aria_value, |this, value| this.aria_value(value))
238 .aria_expanded(expanded)
239 .style(button_style)
240 .when_some(self.trigger_icon.filter(|_| self.chevron), |this, icon| {
241 this.end_icon(
242 Icon::new(icon).size(IconSize::XSmall).color(Color::Muted),
243 )
244 })
245 .when(full_width, |this| this.full_width())
246 .size(trigger_size)
247 .disabled(self.disabled)
248 .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)),
249 ),
250 None,
251 ),
252 LabelKind::Element(element) => (
253 None,
254 Some(
255 a11y_actions_element(ButtonLike::new(self.id.clone()))
256 .aria_role(Role::ComboBox)
257 .when_some(self.aria_label, |this, label| this.aria_label(label))
258 .when_some(aria_description, |this, description| {
259 this.aria_description(description)
260 })
261 .when_some(aria_value, |this, value| this.aria_value(value))
262 .aria_expanded(expanded)
263 .child(element)
264 .style(button_style)
265 .when(self.chevron, |this| {
266 this.child(
267 Icon::new(IconName::ChevronUpDown)
268 .size(IconSize::XSmall)
269 .color(Color::Muted),
270 )
271 })
272 .when(full_width, |this| this.full_width())
273 .size(trigger_size)
274 .disabled(self.disabled)
275 .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)),
276 ),
277 ),
278 };
279
280 // When the menu opens, move the selection to the current value (or the
281 // first item) so assistive technology announces a meaningful item
282 // immediately, instead of focusing the bare menu container and
283 // announcing only "menu". See the ARIA menu button pattern.
284 let menu_for_open = self.menu.clone();
285 let mut popover = PopoverMenu::new((self.id.clone(), "popover"))
286 .full_width(self.full_width)
287 .with_handle(handle)
288 .on_open(std::rc::Rc::new(move |window, cx| {
289 menu_for_open.update(cx, |menu, cx| {
290 menu.select_toggled_or_first(window, cx);
291 });
292 }))
293 .menu(move |_window, _cx| Some(self.menu.clone()));
294
295 popover = match (text_button, element_button, self.trigger_tooltip) {
296 (Some(text_button), None, Some(tooltip)) => {
297 popover.trigger_with_tooltip(text_button, tooltip)
298 }
299 (Some(text_button), None, None) => popover.trigger(text_button),
300 (None, Some(element_button), Some(tooltip)) => {
301 popover.trigger_with_tooltip(element_button, tooltip)
302 }
303 (None, Some(element_button), None) => popover.trigger(element_button),
304 _ => popover,
305 };
306
307 popover
308 .attach(match self.attach {
309 Some(attach) => attach,
310 None => Anchor::BottomRight,
311 })
312 .when_some(self.offset, |this, offset| this.offset(offset))
313 }
314}
315
316impl Component for DropdownMenu {
317 fn scope() -> ComponentScope {
318 ComponentScope::Input
319 }
320
321 fn name() -> &'static str {
322 "DropdownMenu"
323 }
324
325 fn description() -> &'static str {
326 "A dropdown menu displays a list of actions or options. \
327 A dropdown menu is always activated by clicking a trigger (or via a keybinding)."
328 }
329
330 fn preview(window: &mut Window, cx: &mut App) -> AnyElement {
331 let menu = ContextMenu::build(window, cx, |this, _, _| {
332 this.entry("Option 1", None, |_, _| {})
333 .entry("Option 2", None, |_, _| {})
334 .entry("Option 3", None, |_, _| {})
335 .separator()
336 .entry("Option 4", None, |_, _| {})
337 });
338
339 let menu_with_submenu = ContextMenu::build(window, cx, |this, _, _| {
340 this.entry("Toggle All Docks", None, |_, _| {})
341 .submenu("Editor Layout", |menu, _, _| {
342 menu.entry("Split Up", None, |_, _| {})
343 .entry("Split Down", None, |_, _| {})
344 .separator()
345 .entry("Split Side", None, |_, _| {})
346 })
347 .separator()
348 .entry("Project Panel", None, |_, _| {})
349 .entry("Outline Panel", None, |_, _| {})
350 .separator()
351 .submenu("Autofill", |menu, _, _| {
352 menu.entry("Contact…", None, |_, _| {})
353 .entry("Passwords…", None, |_, _| {})
354 })
355 .submenu_with_icon("Predict", IconName::OmegaPredict, |menu, _, _| {
356 menu.entry("Everywhere", None, |_, _| {})
357 .entry("At Cursor", None, |_, _| {})
358 .entry("Over Here", None, |_, _| {})
359 .entry("Over There", None, |_, _| {})
360 })
361 });
362
363 v_flex()
364 .gap_6()
365 .children(vec![
366 example_group_with_title(
367 "Basic Usage",
368 vec![
369 single_example(
370 "Default",
371 DropdownMenu::new("default", "Select an option", menu.clone())
372 .into_any_element(),
373 ),
374 single_example(
375 "Full Width",
376 DropdownMenu::new("full-width", "Full Width Dropdown", menu.clone())
377 .full_width(true)
378 .into_any_element(),
379 ),
380 ],
381 ),
382 example_group_with_title(
383 "Submenus",
384 vec![single_example(
385 "With Submenus",
386 DropdownMenu::new("submenu", "Submenu", menu_with_submenu)
387 .into_any_element(),
388 )],
389 ),
390 example_group_with_title(
391 "Styles",
392 vec![
393 single_example(
394 "Outlined",
395 DropdownMenu::new("outlined", "Outlined Dropdown", menu.clone())
396 .style(DropdownStyle::Outlined)
397 .into_any_element(),
398 ),
399 single_example(
400 "Ghost",
401 DropdownMenu::new("ghost", "Ghost Dropdown", menu.clone())
402 .style(DropdownStyle::Ghost)
403 .into_any_element(),
404 ),
405 ],
406 ),
407 example_group_with_title(
408 "States",
409 vec![single_example(
410 "Disabled",
411 DropdownMenu::new("disabled", "Disabled Dropdown", menu)
412 .disabled(true)
413 .into_any_element(),
414 )],
415 ),
416 ])
417 .into_any_element()
418 }
419}
420