1use gpui::Corners;
2use gpui::{
3 Anchor, App, Context, Edges, ElementId, InteractiveElement as _, IntoElement, ParentElement,
4 RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder,
5};
6use rust_i18n::t;
7
8use crate::{
9 Disableable, Selectable, Sizable, Size, StyledExt as _,
10 menu::{DropdownMenu, PopupMenu},
11};
12
13use super::{Button, ButtonVariant, ButtonVariants};
14
15const HALVES_GROUP: &str = "dropdown-button";
17
18#[derive(IntoElement)]
26pub struct DropdownButton {
27 id: ElementId,
28 style: StyleRefinement,
29 button: Option<Button>,
30 menu:
31 Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>,
32 selected: bool,
33 disabled: bool,
34 menu_accessibility_label: Option<SharedString>,
35 outline: bool,
38 variant: Option<ButtonVariant>,
39 size: Option<Size>,
40 anchor: Anchor,
41}
42
43impl DropdownButton {
44 pub fn new(id: impl Into<ElementId>) -> Self {
46 Self {
47 id: id.into(),
48 style: StyleRefinement::default(),
49 button: None,
50 menu: None,
51 selected: false,
52 disabled: false,
53 menu_accessibility_label: None,
54 outline: false,
55 variant: None,
56 size: None,
57 anchor: Anchor::TopRight,
58 }
59 }
60
61 fn effective_variant(&self) -> ButtonVariant {
62 self.variant
63 .or_else(|| self.button.as_ref().map(Button::variant))
64 .unwrap_or_default()
65 }
66
67 fn effective_size(&self) -> Size {
68 self.size
69 .or_else(|| self.button.as_ref().map(Button::button_size))
70 .unwrap_or_default()
71 }
72
73 pub fn button(mut self, button: Button) -> Self {
80 self.button = Some(button);
81 self
82 }
83
84 pub fn dropdown_menu(
86 mut self,
87 menu: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
88 ) -> Self {
89 self.menu = Some(Box::new(menu));
90 self
91 }
92
93 pub fn dropdown_menu_with_anchor(
95 mut self,
96 anchor: impl Into<Anchor>,
97 menu: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
98 ) -> Self {
99 self.menu = Some(Box::new(menu));
100 self.anchor = anchor.into();
101 self
102 }
103
104 pub fn menu_accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
110 self.menu_accessibility_label = Some(label.into());
111 self
112 }
113
114 pub fn outline(mut self) -> Self {
118 self.outline = true;
119 self
120 }
121}
122
123impl Disableable for DropdownButton {
124 fn disabled(mut self, disabled: bool) -> Self {
125 self.disabled = disabled;
126 self
127 }
128}
129
130impl Styled for DropdownButton {
131 fn style(&mut self) -> &mut gpui::StyleRefinement {
132 &mut self.style
133 }
134}
135
136impl Sizable for DropdownButton {
137 fn with_size(mut self, size: impl Into<Size>) -> Self {
138 self.size = Some(size.into());
139 self
140 }
141}
142
143impl ButtonVariants for DropdownButton {
144 fn with_variant(mut self, variant: ButtonVariant) -> Self {
145 self.variant = Some(variant);
146 self
147 }
148}
149
150impl Selectable for DropdownButton {
151 fn selected(mut self, selected: bool) -> Self {
152 self.selected = selected;
153 self
154 }
155
156 fn is_selected(&self) -> bool {
157 self.selected
158 }
159}
160
161impl RenderOnce for DropdownButton {
162 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
163 debug_assert!(
164 self.button.is_some() || self.menu.is_some(),
165 "a DropdownButton needs a `button`, a `dropdown_menu`, or both"
166 );
167
168 let variant = self.effective_variant();
169 let size = self.effective_size();
170 let selected = self.selected || self.button.as_ref().is_some_and(Selectable::is_selected);
171 let is_ghost = variant.is_ghost();
175 let menu_open = window.use_keyed_state(self.id.clone(), cx, |_, _| false);
176 let is_menu_open = *menu_open.read(cx);
177
178 div()
179 .id(self.id)
180 .when(is_ghost, |this| this.group(HALVES_GROUP))
181 .h_flex()
182 .refine_style(&self.style)
183 .when_some(self.button, |this, button| {
184 let disabled = self.disabled || button.is_disabled();
185 this.child(
186 button
187 .border_corners(Corners {
188 top_left: true,
189 top_right: false,
190 bottom_left: true,
191 bottom_right: false,
192 })
193 .border_edges(Edges::all(true))
194 .selected(selected)
195 .disabled(disabled)
196 .when(self.outline, |this| this.outline())
197 .with_size(size)
198 .with_variant(variant)
199 .when(is_ghost, |this| {
200 this.hover_group(HALVES_GROUP)
201 .hover_group_held(is_menu_open)
202 }),
203 )
204 })
205 .when_some(self.menu, |this, menu| {
206 this.child(
207 Button::new("popup")
208 .dropdown_caret(true)
209 .accessibility_label(
210 self.menu_accessibility_label
211 .unwrap_or_else(|| t!("DropdownButton.more_options").into()),
212 )
213 .border_corners(Corners {
214 top_left: false,
215 top_right: true,
216 bottom_left: false,
217 bottom_right: true,
218 })
219 .border_edges(Edges {
220 left: false,
221 top: true,
222 right: true,
223 bottom: true,
224 })
225 .selected(selected)
226 .disabled(self.disabled)
227 .when(self.outline, |this| this.outline())
228 .with_size(size)
229 .with_variant(variant)
230 .when(is_ghost, |this| this.hover_group(HALVES_GROUP))
231 .dropdown_menu_with_anchor(self.anchor, menu)
232 .on_open_change(move |open, _, cx| {
233 menu_open.update(cx, |state, cx| {
234 *state = *open;
235 cx.notify();
236 })
237 }),
238 )
239 })
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[gpui::test]
248 fn test_dropdown_button_builder(_cx: &mut gpui::TestAppContext) {
249 let button = Button::new("inner").label("Action");
250 let dropdown = DropdownButton::new("complex-dropdown")
251 .button(button)
252 .primary()
253 .outline()
254 .large()
255 .disabled(false)
256 .selected(false)
257 .dropdown_menu_with_anchor(Anchor::BottomLeft, |menu, _, _| menu);
258
259 assert!(dropdown.button.is_some());
260 assert_eq!(dropdown.variant, Some(ButtonVariant::Primary));
261 assert!(dropdown.outline);
262 assert_eq!(dropdown.size, Some(Size::Large));
263 assert!(!dropdown.disabled);
264 assert!(!dropdown.selected);
265 assert!(dropdown.menu.is_some());
266 assert_eq!(dropdown.anchor, Anchor::BottomLeft);
267 }
268
269 #[gpui::test]
272 fn inner_button_keeps_its_own_variant_and_size(_cx: &mut gpui::TestAppContext) {
273 let dropdown = DropdownButton::new("dropdown")
274 .button(Button::new("inner").label("Action").danger().small())
275 .dropdown_menu(|menu, _, _| menu);
276
277 assert_eq!(dropdown.variant, None);
278 assert_eq!(dropdown.size, None);
279 }
280
281 #[gpui::test]
282 fn inner_ghost_becomes_the_split_variant(_cx: &mut gpui::TestAppContext) {
283 let dropdown = DropdownButton::new("dropdown")
284 .button(Button::new("inner").label("Action").ghost())
285 .dropdown_menu(|menu, _, _| menu);
286
287 assert_eq!(dropdown.effective_variant(), ButtonVariant::Ghost);
288 }
289
290 #[gpui::test]
291 fn inner_size_becomes_the_split_size(_cx: &mut gpui::TestAppContext) {
292 let dropdown = DropdownButton::new("dropdown")
293 .button(Button::new("inner").label("Action").small())
294 .dropdown_menu(|menu, _, _| menu);
295
296 assert_eq!(dropdown.effective_size(), Size::Small);
297 }
298}