1use super::{ButtonSize, MaterialButton};
13use crate::{
14 motion::{self, MotionToken},
15 tokens::typography::rem,
16};
17use gpui::{prelude::*, *};
18use std::{cell::Cell, rc::Rc};
19
20pub const EXPANDED_RATIO: f32 = 0.15;
22
23pub const fn standard_group_gap(size: ButtonSize) -> f32 {
25 match size {
26 ButtonSize::ExtraSmall => 18.,
27 ButtonSize::Small => 12.,
28 _ => 8.,
29 }
30}
31
32pub(crate) fn width_deltas(natural: &[f32], pressed: Option<usize>) -> Vec<f32> {
36 let mut deltas = vec![0.; natural.len()];
37 let Some(pressed) = pressed.filter(|&index| natural.get(index).is_some_and(|&w| w > 0.)) else {
38 return deltas;
39 };
40 let neighbours: Vec<usize> = [pressed.checked_sub(1), Some(pressed + 1)]
41 .into_iter()
42 .flatten()
43 .filter(|&index| index < natural.len())
44 .collect();
45 if neighbours.is_empty() {
46 return deltas;
47 }
48 let growth = natural[pressed] * EXPANDED_RATIO;
49 deltas[pressed] = growth;
50 for index in &neighbours {
51 deltas[*index] = -growth / neighbours.len() as f32;
52 }
53 deltas
54}
55
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
59pub enum ButtonDensity {
60 #[default]
61 Default,
62 Comfortable,
63 Compact,
64}
65
66impl ButtonDensity {
67 pub const ALL: [Self; 3] = [Self::Default, Self::Comfortable, Self::Compact];
68 pub const fn offset(self) -> f32 {
70 match self {
71 Self::Default => 0.,
72 Self::Comfortable => -4.,
73 Self::Compact => -8.,
74 }
75 }
76}
77
78#[derive(Default)]
81struct GroupState {
82 items: Vec<ItemState>,
83}
84
85struct ItemState {
86 id: ElementId,
87 pressed: Rc<Cell<bool>>,
88 natural: Rc<Cell<f32>>,
89}
90
91#[derive(gpui::IntoElement)]
92pub struct ButtonGroup {
93 id: ElementId,
94 label: SharedString,
95 size: ButtonSize,
96 vertical: bool,
97 density: ButtonDensity,
98 equal_widths: bool,
99 items: Vec<MaterialButton>,
100}
101
102impl ButtonGroup {
103 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
105 Self {
106 id: id.into(),
107 label: label.into(),
108 size: ButtonSize::Small,
109 vertical: false,
110 density: ButtonDensity::Default,
111 equal_widths: false,
112 items: Vec::new(),
113 }
114 }
115 pub fn button_size(mut self, size: ButtonSize) -> Self {
117 self.size = size;
118 self
119 }
120 pub fn vertical(mut self, vertical: bool) -> Self {
122 self.vertical = vertical;
123 self
124 }
125 pub fn density(mut self, density: ButtonDensity) -> Self {
127 self.density = density;
128 self
129 }
130 pub fn equal_widths(mut self, equal: bool) -> Self {
132 self.equal_widths = equal;
133 self
134 }
135 pub fn item(mut self, item: impl Into<MaterialButton>) -> Self {
137 self.items.push(item.into());
138 self
139 }
140 pub fn items<I: Into<MaterialButton>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
141 self.items.extend(items.into_iter().map(Into::into));
142 self
143 }
144}
145
146impl RenderOnce for ButtonGroup {
147 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
148 let state = window.use_keyed_state((self.id.clone(), "button-group"), cx, |_, _| {
149 GroupState::default()
150 });
151 let count = self.items.len();
152 let (probes, naturals, ids) = state.update(cx, |state, _| {
153 while state.items.len() < count {
154 let index = state.items.len();
155 state.items.push(ItemState {
156 id: ElementId::NamedChild(
157 std::sync::Arc::new((self.id.clone(), "button-group-width").into()),
158 index.to_string().into(),
159 ),
160 pressed: Rc::default(),
161 natural: Rc::default(),
162 });
163 }
164 state.items.truncate(count);
165 let items = &state.items;
166 (
167 items
168 .iter()
169 .map(|item| item.pressed.clone())
170 .collect::<Vec<_>>(),
171 items
172 .iter()
173 .map(|item| item.natural.clone())
174 .collect::<Vec<_>>(),
175 items.iter().map(|item| item.id.clone()).collect::<Vec<_>>(),
176 )
177 });
178 let pressed = probes.iter().position(|probe| probe.get());
179 let natural: Vec<f32> = naturals.iter().map(|extent| extent.get()).collect();
180 let targets = width_deltas(&natural, pressed);
181 let vertical = self.vertical;
182 let equal = self.equal_widths;
183 let widest = natural.iter().copied().fold(0., f32::max);
187 let height = (self.size.height() + self.density.offset()).max(0.);
188 let dense = self.density != ButtonDensity::Default;
189 let mut row = div()
190 .id(self.id)
191 .role(Role::Group)
192 .aria_label(self.label)
193 .flex()
194 .map(|row| {
195 if vertical {
196 row.flex_col()
197 } else {
198 row.flex_row()
199 }
200 })
201 .flex_shrink_0()
202 .map(|row| match (vertical, equal) {
203 (true, true) => row.items_stretch(),
204 _ => row.items_center(),
205 })
206 .gap(rem(standard_group_gap(self.size)));
207 for (index, button) in self.items.into_iter().enumerate() {
208 let delta = motion::token_value(
209 ids[index].clone(),
210 targets[index],
211 MotionToken::FastSpatial,
212 window,
213 cx,
214 );
215 let moving = delta != 0.;
216 let extent = natural[index] + delta;
217 let button = button
218 .press_probe(probes[index].clone())
219 .when(dense, |button| button.h(rem(height)))
220 .when(equal && !vertical && widest > 0., |button| {
221 button.min_w(px(widest))
222 })
223 .when(equal && vertical, |button| button.w_full())
224 .when(moving && natural[index] > 0., |button| {
225 if vertical {
226 button.h(px(extent.max(0.))).min_h(px(0.))
227 } else {
228 button.w(px(extent.max(0.))).min_w(px(0.))
229 }
230 });
231 let measured = naturals[index].clone();
232 row = row.child(
233 div().relative().flex().flex_shrink_0().child(button).child(
234 canvas(
235 move |bounds, window, _| {
236 let extent = f32::from(if vertical {
238 bounds.size.height
239 } else {
240 bounds.size.width
241 });
242 if !moving && (measured.get() - extent).abs() > 0.01 {
243 measured.set(extent);
244 if equal && !vertical {
245 window.request_animation_frame();
247 }
248 }
249 },
250 |_, _, _, _| {},
251 )
252 .absolute()
253 .size_full(),
254 ),
255 );
256 }
257 row
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::{ButtonGroup, EXPANDED_RATIO, standard_group_gap, width_deltas};
264 use crate::components::{ButtonSize, InteractionPreview, MaterialButton};
265 use crate::motion;
266 use gpui::{
267 Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
268 TestAppContext, VisualTestContext, Window, div, prelude::FluentBuilder as _, px,
269 };
270 use std::time::Duration;
271
272 const ITEMS: [&str; 3] = ["item-0", "item-1", "item-2"];
273
274 #[core::prelude::v1::test]
275 fn the_pressed_item_grows_and_its_neighbours_pay() {
276 assert_eq!(width_deltas(&[100., 100., 100.], None), [0., 0., 0.]);
277 let deltas = width_deltas(&[100., 80., 100., 60.], Some(1));
278 let growth = 80. * EXPANDED_RATIO;
279 assert_eq!(deltas, [-growth / 2., growth, -growth / 2., 0.]);
280 assert!(deltas.iter().sum::<f32>().abs() < 1e-4, "the extent stays");
281 let end = width_deltas(&[100., 100.], Some(1));
282 assert_eq!(end, [-100. * EXPANDED_RATIO, 100. * EXPANDED_RATIO]);
283 assert_eq!(width_deltas(&[0., 100.], Some(0)), [0., 0.], "unmeasured");
284 assert_eq!(
285 width_deltas(&[100.], Some(0)),
286 [0.],
287 "nothing to borrow from"
288 );
289 assert_eq!(standard_group_gap(ButtonSize::ExtraSmall), 18.);
290 assert_eq!(standard_group_gap(ButtonSize::Large), 8.);
291 }
292
293 struct Harness {
294 pressed: bool,
295 }
296 impl Render for Harness {
297 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
298 window.set_rem_size(px(14.));
299 div()
300 .flex()
301 .items_start()
302 .p(px(40.))
303 .child(
304 ButtonGroup::new("group", "Group").items((0..3usize).map(|index| {
305 MaterialButton::new(("item", index))
306 .debug_selector(move || ITEMS[index].into())
307 .child("Label")
308 .when(index == 1 && self.pressed, |button| {
309 button.preview_state(InteractionPreview::Pressed)
310 })
311 })),
312 )
313 }
314 }
315
316 fn draw(cx: &mut VisualTestContext) {
317 cx.update(|window, cx| {
318 window.refresh();
319 window.draw(cx).clear(cx);
320 });
321 }
322
323 #[gpui::test]
324 fn pressing_widens_an_item_and_release_restores_it(cx: &mut TestAppContext) {
325 cx.update(|cx| {
326 gpui_base::init(cx);
327 cx.set_reduce_motion(false);
328 motion::set_motion_scheme(motion::MotionScheme::Expressive);
329 });
330 let (view, cx) = cx.add_window_view(|_, _| Harness { pressed: false });
331 draw(cx);
332 draw(cx);
333 let widths = |cx: &mut VisualTestContext| {
334 ITEMS.map(|item| cx.debug_bounds(item).unwrap().size.width)
335 };
336 let rest = widths(cx);
337 let extent = |cx: &mut VisualTestContext| {
338 cx.debug_bounds("item-2").unwrap().right() - cx.debug_bounds("item-0").unwrap().left()
339 };
340 let rest_extent = extent(cx);
341 view.update(cx, |view, cx| {
342 view.pressed = true;
343 cx.notify();
344 });
345 for _ in 0..3 {
346 draw(cx);
347 }
348 cx.executor().advance_clock(Duration::from_secs(2));
349 draw(cx);
350 draw(cx);
351 let pressed = widths(cx);
352 assert!(
353 (pressed[1] - rest[1] * (1. + EXPANDED_RATIO)).abs() < px(0.5),
354 "{pressed:?} vs {rest:?}"
355 );
356 assert!(pressed[0] < rest[0] && pressed[2] < rest[2]);
357 assert!(
358 (extent(cx) - rest_extent).abs() < px(0.5),
359 "the group keeps its extent"
360 );
361 view.update(cx, |view, cx| {
362 view.pressed = false;
363 cx.notify();
364 });
365 for _ in 0..3 {
366 draw(cx);
367 }
368 cx.executor().advance_clock(Duration::from_secs(2));
369 draw(cx);
370 draw(cx);
371 assert_eq!(widths(cx), rest);
372 }
373}
374
375#[cfg(test)]
376mod layout_tests {
377 use super::{ButtonDensity, ButtonGroup};
378 use crate::components::MaterialButton;
379 use gpui::{
380 Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
381 TestAppContext, VisualTestContext, Window, div, px,
382 };
383
384 const ITEMS: [&str; 3] = ["item-0", "item-1", "item-2"];
385 const LABELS: [&str; 3] = ["S", "Medium", "Extra large"];
386
387 struct Harness {
388 vertical: bool,
389 density: ButtonDensity,
390 equal: bool,
391 }
392 impl Render for Harness {
393 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
394 window.set_rem_size(px(14.));
395 div().flex().items_start().p(px(40.)).child(
396 ButtonGroup::new("group", "Sizes")
397 .vertical(self.vertical)
398 .density(self.density)
399 .equal_widths(self.equal)
400 .items((0..3usize).map(|index| {
401 MaterialButton::new(("item", index))
402 .debug_selector(move || ITEMS[index].into())
403 .child(LABELS[index])
404 })),
405 )
406 }
407 }
408
409 fn draw(cx: &mut VisualTestContext) {
410 for _ in 0..3 {
411 cx.update(|window, cx| {
412 window.refresh();
413 window.draw(cx).clear(cx);
414 });
415 }
416 }
417
418 fn bounds(harness: Harness, cx: &mut TestAppContext) -> [gpui::Bounds<gpui::Pixels>; 3] {
419 cx.update(|cx| {
420 gpui_base::init(cx);
421 cx.set_reduce_motion(true);
422 });
423 let (_, cx) = cx.add_window_view(move |_, _| harness);
424 draw(cx);
425 ITEMS.map(|item| cx.debug_bounds(item).unwrap())
426 }
427
428 #[gpui::test]
429 fn a_vertical_compact_group_stacks_shorter_buttons(cx: &mut TestAppContext) {
430 let [first, second, _] = bounds(
431 Harness {
432 vertical: true,
433 density: ButtonDensity::Compact,
434 equal: false,
435 },
436 cx,
437 );
438 assert_eq!(first.size.height, px(40. - 8.));
439 assert_eq!(second.top(), first.bottom() + px(12.), "the S gap");
440 }
441
442 #[gpui::test]
443 fn equal_widths_share_the_widest_button(cx: &mut TestAppContext) {
444 let [a, b, c] = bounds(
445 Harness {
446 vertical: false,
447 density: ButtonDensity::Default,
448 equal: true,
449 },
450 cx,
451 );
452 assert!(c.size.width > px(80.));
453 assert_eq!(a.size.width, c.size.width);
454 assert_eq!(b.size.width, c.size.width);
455 }
456
457 #[gpui::test]
458 fn equal_vertical_buttons_stretch_to_the_column(cx: &mut TestAppContext) {
459 let [a, _, c] = bounds(
460 Harness {
461 vertical: true,
462 density: ButtonDensity::Comfortable,
463 equal: true,
464 },
465 cx,
466 );
467 assert_eq!(a.size.width, c.size.width);
468 assert_eq!(a.size.height, px(36.));
469 }
470}