1use super::{
13 ButtonShape, ButtonSize, ButtonVariant, InteractionPreview, MaterialButton, MaterialMenu,
14 MenuPopup,
15 button::{Form, Segment, segment_metrics},
16};
17use crate::{
18 icons::{Icon, IconName},
19 motion::{self, MotionToken},
20};
21use gpui::{prelude::*, *};
22use std::rc::Rc;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub enum SplitButtonPart {
27 Leading,
28 Trailing,
29}
30
31type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
32type ToggleHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
33
34#[derive(gpui::IntoElement)]
35pub struct SplitButton {
36 id: ElementId,
37 label: SharedString,
38 menu_label: SharedString,
39 icon: Option<Icon>,
40 variant: ButtonVariant,
41 size: ButtonSize,
42 open: bool,
43 disabled: bool,
44 preview: Option<(SplitButtonPart, InteractionPreview)>,
45 menu: Option<MaterialMenu>,
46 on_click: Option<ClickHandler>,
47 on_toggle: Option<ToggleHandler>,
48}
49
50impl SplitButton {
51 pub fn new(
54 id: impl Into<ElementId>,
55 label: impl Into<SharedString>,
56 menu_label: impl Into<SharedString>,
57 ) -> Self {
58 Self {
59 id: id.into(),
60 label: label.into(),
61 menu_label: menu_label.into(),
62 icon: None,
63 variant: ButtonVariant::Filled,
64 size: ButtonSize::Small,
65 open: false,
66 disabled: false,
67 preview: None,
68 menu: None,
69 on_click: None,
70 on_toggle: None,
71 }
72 }
73 pub fn menu(mut self, menu: MaterialMenu) -> Self {
77 self.menu = Some(menu);
78 self
79 }
80 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
81 self.icon = Some(icon.into());
82 self
83 }
84 pub fn variant(mut self, variant: ButtonVariant) -> Self {
86 self.variant = variant;
87 self
88 }
89 pub fn button_size(mut self, size: ButtonSize) -> Self {
90 self.size = size;
91 self
92 }
93 pub fn open(mut self, open: bool) -> Self {
95 self.open = open;
96 self
97 }
98 pub fn disabled(mut self, disabled: bool) -> Self {
99 self.disabled = disabled;
100 self
101 }
102 pub fn preview_state(mut self, part: SplitButtonPart, state: InteractionPreview) -> Self {
105 self.preview = Some((part, state));
106 self
107 }
108 pub fn on_click(
110 mut self,
111 callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
112 ) -> Self {
113 self.on_click = Some(Rc::new(callback));
114 self
115 }
116 pub fn on_toggle(mut self, callback: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
118 self.on_toggle = Some(Rc::new(callback));
119 self
120 }
121
122 fn preview_of(&self, part: SplitButtonPart) -> Option<InteractionPreview> {
123 self.preview.map(|(previewed, state)| {
124 if previewed == part {
125 state
126 } else if state.is_disabled() {
127 InteractionPreview::Disabled
128 } else {
129 InteractionPreview::Enabled
130 }
131 })
132 }
133}
134
135fn chevron_turns(open: bool) -> f32 {
137 if open { 0.5 } else { 0. }
138}
139
140impl RenderOnce for SplitButton {
141 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
142 let turns = motion::token_value(
143 (self.id.clone(), "split-chevron"),
144 chevron_turns(self.open),
145 MotionToken::FastSpatial,
146 window,
147 cx,
148 );
149 let metrics = segment_metrics(self.size);
150 let leading_preview = self.preview_of(SplitButtonPart::Leading);
151 let trailing_preview = self.preview_of(SplitButtonPart::Trailing);
152 let mut leading = MaterialButton::new((self.id.clone(), "split-leading"))
153 .variant(self.variant)
154 .button_size(self.size)
155 .shape(ButtonShape::Round)
156 .form(Form::Segment(Segment::Leading))
157 .disabled(self.disabled)
158 .when_some(self.icon, |button, icon| button.icon(icon))
159 .child(self.label);
160 if let Some(state) = leading_preview {
161 leading = leading.preview_state(state);
162 }
163 if let Some(callback) = self.on_click {
164 leading = leading.on_click(move |event, window, cx| callback(event, window, cx));
165 }
166 let chevron = Icon::from(IconName::ChevronDown)
167 .size(crate::tokens::typography::rem(metrics.trailing_icon))
168 .rotate(turns * std::f32::consts::TAU);
169 let open = self.open;
170 let mut trailing = MaterialButton::new((self.id.clone(), "split-trailing"))
171 .variant(self.variant)
172 .button_size(self.size)
173 .form(Form::Segment(Segment::Trailing))
174 .open(open)
175 .disabled(self.disabled)
176 .icon(chevron)
177 .accessibility_label(self.menu_label)
178 .aria_expanded(open);
179 if let Some(state) = trailing_preview {
180 trailing = trailing.preview_state(state);
181 }
182 let has_menu = self.menu.is_some() && !self.disabled;
183 if let Some(callback) = self.on_toggle.clone() {
184 trailing = trailing.on_click(move |event, window, cx| {
188 if !has_menu || matches!(event, ClickEvent::Keyboard(_)) {
189 callback(&!open, window, cx)
190 }
191 });
192 }
193 let trailing = match self.menu {
194 Some(menu) if has_menu => {
195 let mut popup = MenuPopup::new((self.id.clone(), "split-menu"), trailing, menu)
196 .open(open)
197 .anchor(Anchor::TopRight);
198 if let Some(callback) = self.on_toggle {
199 popup =
200 popup.on_open_change(move |open, window, cx| callback(open, window, cx));
201 }
202 popup.into_any_element()
203 }
204 _ => trailing.into_any_element(),
205 };
206 div()
207 .id(self.id)
208 .role(Role::Group)
209 .flex()
210 .flex_row()
211 .flex_shrink_0()
212 .items_center()
213 .gap(crate::tokens::typography::rem(2.))
214 .child(leading)
215 .child(trailing)
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::{SplitButton, SplitButtonPart, chevron_turns};
222 use crate::components::{ButtonSize, InteractionPreview};
223 use gpui::{
224 Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
225 TestAppContext, VisualTestContext, Window, div, px,
226 };
227
228 struct Harness {
229 size: ButtonSize,
230 open: bool,
231 }
232 impl Render for Harness {
233 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
234 window.set_rem_size(px(14.));
235 div().flex().items_start().p(px(40.)).child(
236 div().flex().debug_selector(|| "split".into()).child(
237 SplitButton::new("split", "Label", "More")
238 .button_size(self.size)
239 .open(self.open)
240 .preview_state(SplitButtonPart::Trailing, InteractionPreview::Enabled),
241 ),
242 )
243 }
244 }
245
246 #[gpui::test]
247 fn segments_share_the_button_height_with_a_two_dp_gap(cx: &mut TestAppContext) {
248 cx.update(|cx| {
249 gpui_base::init(cx);
250 cx.set_reduce_motion(true);
251 });
252 for (size, height) in [
253 (ButtonSize::ExtraSmall, 32.),
254 (ButtonSize::Medium, 56.),
255 (ButtonSize::ExtraLarge, 136.),
256 ] {
257 let (_, cx) = cx.add_window_view(|_, _| Harness { size, open: true });
258 cx.update(|window, cx| {
259 window.refresh();
260 window.draw(cx).clear(cx);
261 });
262 let bounds = cx.debug_bounds("split").unwrap();
263 assert_eq!(bounds.size.height, px(height), "{size:?}");
264 let _: &mut VisualTestContext = cx;
265 }
266 }
267
268 #[core::prelude::v1::test]
269 fn the_chevron_turns_half_way_while_open() {
270 assert_eq!(chevron_turns(false), 0.);
271 assert_eq!(chevron_turns(true), 0.5);
272 }
273
274 #[core::prelude::v1::test]
275 fn a_previewed_segment_leaves_its_neighbor_at_rest() {
276 let split = SplitButton::new("s", "Label", "More")
277 .preview_state(SplitButtonPart::Leading, InteractionPreview::Pressed);
278 assert_eq!(
279 split.preview_of(SplitButtonPart::Leading),
280 Some(InteractionPreview::Pressed)
281 );
282 assert_eq!(
283 split.preview_of(SplitButtonPart::Trailing),
284 Some(InteractionPreview::Enabled)
285 );
286 let disabled = SplitButton::new("s", "Label", "More")
287 .preview_state(SplitButtonPart::Leading, InteractionPreview::Disabled);
288 assert_eq!(
289 disabled.preview_of(SplitButtonPart::Trailing),
290 Some(InteractionPreview::Disabled)
291 );
292 }
293}
294
295#[cfg(test)]
296mod menu_tests {
297 use super::SplitButton;
298 use crate::components::{MaterialMenu, MaterialMenuItem};
299 use gpui::{
300 Context, Entity, InteractiveElement as _, IntoElement, Modifiers, ParentElement as _,
301 Render, Styled as _, TestAppContext, VisualTestContext, Window, div, px,
302 };
303 use std::{cell::RefCell, rc::Rc};
304
305 struct Harness {
306 open: bool,
307 picked: Rc<RefCell<Vec<usize>>>,
308 }
309 impl Render for Harness {
310 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
311 window.set_rem_size(px(14.));
312 let menu = MaterialMenu::new("menu").items((0..3).map(|ix| {
313 let picked = self.picked.clone();
314 MaterialMenuItem::new("Item").on_click(move |_, _, _| picked.borrow_mut().push(ix))
315 }));
316 div().flex().items_start().p(px(20.)).pl(px(400.)).child(
317 div().flex().debug_selector(|| "split".into()).child(
318 SplitButton::new("split", "Save", "More")
319 .menu(menu)
320 .open(self.open)
321 .on_toggle(cx.listener(|this, open: &bool, _, cx| {
322 this.open = *open;
323 cx.notify();
324 })),
325 ),
326 )
327 }
328 }
329
330 fn settle(cx: &mut VisualTestContext) {
331 for _ in 0..3 {
332 cx.executor()
333 .advance_clock(std::time::Duration::from_millis(600));
334 cx.update(|window, cx| {
335 window.refresh();
336 window.draw(cx).clear(cx);
337 });
338 }
339 }
340
341 fn setup(cx: &mut TestAppContext) -> (Entity<Harness>, &mut VisualTestContext) {
342 cx.update(|cx| {
343 gpui_kit::init(cx);
344 crate::init(cx);
345 cx.set_reduce_motion(true);
346 });
347 cx.add_window_view(|_, _| Harness {
348 open: false,
349 picked: Rc::default(),
350 })
351 }
352
353 #[gpui::test]
354 fn the_trailing_segment_opens_the_menu_and_an_item_closes_it(cx: &mut TestAppContext) {
355 let (view, cx) = setup(cx);
356 settle(cx);
357 let split = cx.debug_bounds("split").unwrap();
358 let chevron = gpui::point(split.right() - px(20.), split.center().y);
360 cx.simulate_click(chevron, Modifiers::default());
361 settle(cx);
362 cx.update(|_, cx| assert!(view.read(cx).open, "a press opens the menu once"));
363 let surface = cx.debug_bounds("menu-popup-surface").unwrap();
364 assert!(surface.top() >= split.bottom(), "the menu opens below");
365 assert_eq!(
366 surface.right(),
367 split.right(),
368 "end-aligned with the button"
369 );
370 cx.simulate_keystrokes("down enter");
371 settle(cx);
372 cx.update(|_, cx| {
373 assert_eq!(*view.read(cx).picked.borrow(), vec![0]);
374 assert!(!view.read(cx).open, "activating an item closes the menu");
375 });
376 }
377
378 #[gpui::test]
379 fn the_leading_segment_does_not_open_the_menu(cx: &mut TestAppContext) {
380 let (view, cx) = setup(cx);
381 settle(cx);
382 let split = cx.debug_bounds("split").unwrap();
383 let leading = gpui::point(split.left() + px(16.), split.center().y);
384 cx.simulate_click(leading, Modifiers::default());
385 settle(cx);
386 cx.update(|_, cx| assert!(!view.read(cx).open));
387 }
388}