1use gpui::Corners;
2use gpui::InteractiveElement;
3use gpui::ParentElement;
4use gpui::{App, Axis, Edges, ElementId, IntoElement, Window};
5use gpui::{
6 RenderOnce, StatefulInteractiveElement as _, StyleRefinement, Styled, div,
7 prelude::FluentBuilder as _,
8};
9use std::{cell::Cell, rc::Rc};
10
11use crate::{
12 Disableable, Sizable, Size, StyledExt,
13 button::{Button, ButtonVariant, ButtonVariants},
14};
15
16#[derive(IntoElement)]
18pub struct ButtonGroup {
19 id: ElementId,
20 style: StyleRefinement,
21 children: Vec<Button>,
22 pub(super) multiple: bool,
23 pub(super) disabled: bool,
24 pub(super) layout: Axis,
25
26 pub(super) compact: bool,
28 pub(super) outline: bool,
29 pub(super) variant: Option<ButtonVariant>,
30 pub(super) size: Option<Size>,
31
32 on_click: Option<Box<dyn Fn(&Vec<usize>, &mut Window, &mut App) + 'static>>,
33}
34
35impl Disableable for ButtonGroup {
36 fn disabled(mut self, disabled: bool) -> Self {
37 self.disabled = disabled;
38 self
39 }
40}
41
42impl ButtonGroup {
43 pub fn new(id: impl Into<ElementId>) -> Self {
45 Self {
46 id: id.into(),
47 style: StyleRefinement::default(),
48 children: Vec::new(),
49 variant: None,
50 size: None,
51 compact: false,
52 outline: false,
53 multiple: false,
54 disabled: false,
55 layout: Axis::Horizontal,
56 on_click: None,
57 }
58 }
59
60 pub fn child(mut self, child: Button) -> Self {
62 self.children.push(child.disabled(self.disabled));
63 self
64 }
65
66 pub fn children(mut self, children: impl IntoIterator<Item = Button>) -> Self {
68 self.children.extend(children);
69 self
70 }
71
72 pub fn multiple(mut self, multiple: bool) -> Self {
74 self.multiple = multiple;
75 self
76 }
77
78 pub fn layout(mut self, layout: Axis) -> Self {
80 self.layout = layout;
81 self
82 }
83
84 pub fn compact(mut self) -> Self {
88 self.compact = true;
89 self
90 }
91
92 pub fn outline(mut self) -> Self {
96 self.outline = true;
97 self
98 }
99
100 pub fn on_click(
124 mut self,
125 handler: impl Fn(&Vec<usize>, &mut Window, &mut App) + 'static,
126 ) -> Self {
127 self.on_click = Some(Box::new(handler));
128 self
129 }
130}
131
132impl Sizable for ButtonGroup {
133 fn with_size(mut self, size: impl Into<Size>) -> Self {
134 self.size = Some(size.into());
135 self
136 }
137}
138
139impl Styled for ButtonGroup {
140 fn style(&mut self) -> &mut gpui::StyleRefinement {
141 &mut self.style
142 }
143}
144
145impl ButtonVariants for ButtonGroup {
146 fn with_variant(mut self, variant: ButtonVariant) -> Self {
147 self.variant = Some(variant);
148 self
149 }
150}
151
152impl RenderOnce for ButtonGroup {
153 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
154 let children_len = self.children.len();
155 let mut selected_ixs: Vec<usize> = Vec::new();
156 let state = Rc::new(Cell::new(None));
157
158 for (ix, child) in self.children.iter().enumerate() {
159 if child.selected {
160 selected_ixs.push(ix);
161 }
162 }
163
164 let vertical = self.layout == Axis::Vertical;
165
166 div()
167 .id(self.id)
168 .flex()
169 .when(vertical, |this| this.flex_col().justify_center())
170 .when(!vertical, |this| this.items_center())
171 .refine_style(&self.style)
172 .children(
173 self.children
174 .into_iter()
175 .enumerate()
176 .map(|(child_index, child)| {
177 let state = Rc::clone(&state);
178 let selected = child.selected;
181 let child = child.toggled(selected);
182 let child = if children_len == 1 {
183 child
184 } else if child_index == 0 {
185 child
187 .border_corners(Corners {
188 top_left: true,
189 top_right: vertical,
190 bottom_left: !vertical,
191 bottom_right: false,
192 })
193 .border_edges(Edges {
194 left: true,
195 top: true,
196 right: true,
197 bottom: true,
198 })
199 } else if child_index == children_len - 1 {
200 child
202 .border_edges(Edges {
203 left: vertical,
204 top: !vertical,
205 right: true,
206 bottom: true,
207 })
208 .border_corners(Corners {
209 top_left: false,
210 top_right: !vertical,
211 bottom_left: vertical,
212 bottom_right: true,
213 })
214 } else {
215 child
217 .border_corners(Corners {
218 top_left: false,
219 top_right: false,
220 bottom_left: false,
221 bottom_right: false,
222 })
223 .border_edges(Edges {
224 left: vertical,
225 top: !vertical,
226 right: true,
227 bottom: true,
228 })
229 }
230 .when_some(self.size, |this, size| this.with_size(size))
231 .when_some(self.variant, |this, variant| this.with_variant(variant))
232 .when(self.compact, |this| this.compact())
233 .when(self.outline, |this| this.outline())
234 .when(self.on_click.is_some(), |this| {
235 this.on_click(move |_, _, _| {
236 state.set(Some(child_index));
237 })
238 });
239
240 child
241 }),
242 )
243 .when_some(
244 self.on_click.filter(|_| !self.disabled),
245 move |this, on_click| {
246 this.on_click(move |_, window, cx| {
247 let mut selected_ixs = selected_ixs.clone();
248 if let Some(ix) = state.get() {
249 if self.multiple {
250 if let Some(pos) = selected_ixs.iter().position(|&i| i == ix) {
251 selected_ixs.remove(pos);
252 } else {
253 selected_ixs.push(ix);
254 }
255 } else {
256 selected_ixs.clear();
257 selected_ixs.push(ix);
258 }
259 }
260
261 on_click(&selected_ixs, window, cx);
262 })
263 },
264 )
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::Selectable as _;
272 use gpui::{
273 Axis, Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, TestAppContext,
274 VisualTestContext, point, px,
275 };
276 use std::{cell::Cell, rc::Rc};
277
278 struct GroupHarness {
279 multiple: bool,
280 install_group_callback: bool,
281 child_clicks: Rc<Cell<usize>>,
282 group_changes: Rc<std::cell::RefCell<Vec<Vec<usize>>>>,
283 }
284
285 impl Render for GroupHarness {
286 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
287 let child_clicks = self.child_clicks.clone();
288 let mut group = ButtonGroup::new("group")
289 .multiple(self.multiple)
290 .size(px(120.))
291 .child(
292 Button::new("one")
293 .label("One")
294 .on_click(move |_, _, _| child_clicks.set(child_clicks.get() + 1)),
295 )
296 .child(Button::new("two").label("Two").selected(true));
297 if self.install_group_callback {
298 let changes = self.group_changes.clone();
299 group = group.on_click(move |next, _, _| changes.borrow_mut().push(next.clone()));
300 }
301 group
302 }
303 }
304
305 fn group_harness(
306 cx: &mut TestAppContext,
307 multiple: bool,
308 install_group_callback: bool,
309 ) -> (
310 &mut VisualTestContext,
311 Rc<Cell<usize>>,
312 Rc<std::cell::RefCell<Vec<Vec<usize>>>>,
313 ) {
314 cx.update(crate::init);
315 let child_clicks = Rc::new(Cell::new(0));
316 let changes = Rc::new(std::cell::RefCell::new(Vec::new()));
317 let (_, cx) = cx.add_window_view({
318 let child_clicks = child_clicks.clone();
319 let group_changes = changes.clone();
320 move |_, _| GroupHarness {
321 multiple,
322 install_group_callback,
323 child_clicks,
324 group_changes,
325 }
326 });
327 cx.update(|window, cx| window.draw(cx).clear(cx));
328 (cx, child_clicks, changes)
329 }
330
331 fn activate_key(cx: &mut VisualTestContext, key: &str) {
332 let keystroke = Keystroke::parse(key).unwrap();
333 cx.simulate_event(KeyDownEvent {
334 keystroke: keystroke.clone(),
335 is_held: false,
336 prefer_character_input: false,
337 });
338 cx.simulate_event(KeyUpEvent { keystroke });
339 }
340
341 fn click_first(cx: &mut VisualTestContext) {
342 cx.simulate_click(point(px(10.), px(60.)), Modifiers::default());
343 }
344
345 #[gpui::test]
346 fn legacy_group_callback_overrides_the_child_callback(cx: &mut TestAppContext) {
347 let (cx, child_clicks, changes) = group_harness(cx, false, true);
348 click_first(cx);
349 assert_eq!(child_clicks.get(), 0);
350 assert_eq!(changes.borrow().as_slice(), &[vec![0]]);
351 }
352
353 #[gpui::test]
354 fn legacy_child_callback_survives_without_a_group_callback(cx: &mut TestAppContext) {
355 let (cx, child_clicks, changes) = group_harness(cx, false, false);
356 click_first(cx);
357 assert_eq!(child_clicks.get(), 1);
358 assert!(changes.borrow().is_empty());
359 }
360
361 #[gpui::test]
362 fn legacy_single_and_multiple_results_use_the_rendered_selection(cx: &mut TestAppContext) {
363 let (cx, _, single) = group_harness(cx, false, true);
364 click_first(cx);
365 assert_eq!(single.borrow().as_slice(), &[vec![0]]);
366
367 let (cx, _, multiple) = group_harness(cx, true, true);
368 click_first(cx);
369 assert_eq!(multiple.borrow().as_slice(), &[vec![1, 0]]);
370 }
371
372 #[gpui::test]
373 fn legacy_keyboard_click_does_not_reach_the_group_callback(cx: &mut TestAppContext) {
374 let (cx, _, changes) = group_harness(cx, false, true);
375 cx.update(|window, cx| window.focus_next(cx));
376 activate_key(cx, "enter");
377 assert!(changes.borrow().is_empty());
378 }
379
380 #[gpui::test]
381 fn legacy_disabled_state_depends_on_builder_order(cx: &mut TestAppContext) {
382 struct DisabledOrderHarness(Rc<Cell<usize>>);
383
384 impl Render for DisabledOrderHarness {
385 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
386 let first = self.0.clone();
387 let second = self.0.clone();
388 crate::v_flex()
389 .child(
390 ButtonGroup::new("disabled-before-child")
391 .size(px(120.))
392 .disabled(true)
393 .child(
394 Button::new("first")
395 .label("First")
396 .on_click(move |_, _, _| first.set(first.get() + 1)),
397 ),
398 )
399 .child(
400 ButtonGroup::new("disabled-after-child")
401 .size(px(120.))
402 .child(
403 Button::new("second")
404 .label("Second")
405 .on_click(move |_, _, _| second.set(second.get() + 1)),
406 )
407 .disabled(true),
408 )
409 }
410 }
411
412 cx.update(crate::init);
413 let clicks = Rc::new(Cell::new(0));
414 let (_, cx) = cx.add_window_view({
415 let clicks = clicks.clone();
416 move |_, _| DisabledOrderHarness(clicks)
417 });
418 cx.update(|window, cx| window.draw(cx).clear(cx));
419 cx.simulate_click(point(px(10.), px(60.)), Modifiers::default());
420 assert_eq!(clicks.get(), 0);
421 cx.simulate_click(point(px(10.), px(180.)), Modifiers::default());
422 assert_eq!(clicks.get(), 1);
423 }
424
425 #[gpui::test]
426 fn test_button_group_builder(_cx: &mut gpui::TestAppContext) {
427 let group = ButtonGroup::new("complex-group")
428 .child(Button::new("btn1").label("One"))
429 .child(Button::new("btn2").label("Two"))
430 .child(Button::new("btn3").label("Three"))
431 .primary()
432 .large()
433 .outline()
434 .compact()
435 .multiple(true)
436 .layout(Axis::Vertical)
437 .disabled(false)
438 .on_click(|_, _, _| {});
439
440 assert_eq!(group.children.len(), 3);
441 assert_eq!(group.variant, Some(ButtonVariant::Primary));
442 assert_eq!(group.size, Some(Size::Large));
443 assert!(group.outline);
444 assert!(group.compact);
445 assert!(group.multiple);
446 assert_eq!(group.layout, Axis::Vertical);
447 assert!(!group.disabled);
448 assert!(group.on_click.is_some());
449 }
450}