Skip to main content

tessera_components/
button_groups.rs

1//! Material 3 button groups for related actions.
2//!
3//! ## Usage
4//!
5//! Group related actions or connected choices.
6
7use std::collections::HashMap;
8
9use tessera_ui::{
10    AccessibilityActionHandler, AccessibilityNode, AxisConstraint, CallbackWith, ComputedData,
11    Constraint, Dp, FocusTraversalPolicy, LayoutResult, MeasurementError, Modifier, Px, PxPosition,
12    RenderSlot, SemanticsModifierNode, State,
13    accesskit::{Orientation, Role},
14    current_frame_nanos, key,
15    layout::{MeasureScope, layout},
16    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
17    provide_context, receive_frame_nanos, remember, tessera, use_context,
18};
19
20use crate::{
21    animation::shape_motion::ShapeMotion,
22    button::{ButtonShape, ButtonSize, ButtonVariant, button},
23    icon::IconSize,
24    modifier::{InteractionState, InteractionVisualState, ModifierExt, Padding},
25    shape_def::{RoundedCorner, Shape},
26};
27
28use width_motion::{PressWidthMotion, WidthData, WidthObserver, redistribute_widths};
29
30#[cfg(all(test, not(target_family = "wasm")))]
31mod native_tests;
32#[cfg(all(test, not(target_family = "wasm")))]
33mod width_tests;
34
35mod width_motion;
36
37/// According to the Material 3 spec, the [`button_groups`] component supports
38/// two styles: `Standard` and `Connected`.
39#[derive(Debug, Clone, PartialEq, Copy, Default)]
40pub enum ButtonGroupsStyle {
41    /// Buttons have spacing between them and do not need to be the same width.
42    #[default]
43    Standard,
44    /// Buttons use 2dp gaps and equal widths with rounded outer ends.
45    Connected,
46}
47
48/// According to the Material 3 spec, the [`button_groups`] component supports
49/// two selection modes: `Single` and `Multiple`.
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51pub enum ButtonGroupsSelectionMode {
52    /// Only one button can be selected at a time.
53    #[default]
54    Single,
55    /// Multiple buttons can be selected at the same time.
56    Multiple,
57}
58
59/// According to the Material 3 spec, the [`button_groups`] component supports
60/// a series of sizes.
61#[derive(Debug, Clone, PartialEq, Copy, Default)]
62pub enum ButtonGroupsSize {
63    /// Extra small size.
64    ExtraSmall,
65    /// Small size.
66    Small,
67    /// Medium size.
68    #[default]
69    Medium,
70    /// Large size.
71    Large,
72    /// Extra large size.
73    ExtraLarge,
74}
75
76impl ButtonGroupsBuilder {
77    /// Add a child item and click callback.
78    pub fn child<F, C, Output>(&self, child: F, on_click: C) -> Self
79    where
80        F: Fn() -> Output + Send + Sync + 'static,
81        C: Fn(bool) + Send + Sync + 'static,
82    {
83        self.props.append("child_closures", move |props| {
84            props
85                .child_closures
86                .get_or_insert_with(Vec::new)
87                .push(RenderSlot::new(child))
88        });
89        self.props.append("on_click_closures", move |props| {
90            props
91                .on_click_closures
92                .get_or_insert_with(Vec::new)
93                .push(CallbackWith::new(on_click))
94        });
95        Self { props: self.props }
96    }
97
98    /// Add a child item and click callback using shared callbacks.
99    pub fn child_shared(
100        &self,
101        child: impl Into<RenderSlot> + Send + Sync + 'static,
102        on_click: impl Into<CallbackWith<bool>> + Send + Sync + 'static,
103    ) -> Self {
104        self.props.append("child_closures", move |props| {
105            props
106                .child_closures
107                .get_or_insert_with(Vec::new)
108                .push(child.into())
109        });
110        self.props.append("on_click_closures", move |props| {
111            props
112                .on_click_closures
113                .get_or_insert_with(Vec::new)
114                .push(on_click.into())
115        });
116        Self { props: self.props }
117    }
118}
119
120#[derive(Clone, PartialEq)]
121struct ButtonGroupsLayout {
122    container_height: Dp,
123    between_space: Dp,
124    active_button_shape: Shape,
125    inactive_button_shape: Shape,
126    inactive_button_shape_start: Shape,
127    inactive_button_shape_end: Shape,
128    outer_corner: RoundedCorner,
129}
130
131impl ButtonGroupsLayout {
132    fn new(size: ButtonGroupsSize, style: ButtonGroupsStyle, shape: ButtonShape) -> Self {
133        let square = |radius| {
134            let corner = RoundedCorner::manual(radius, 2.0);
135            Shape::RoundedRectangle {
136                top_left: corner,
137                top_right: corner,
138                bottom_right: corner,
139                bottom_left: corner,
140            }
141        };
142        let container_height = ButtonSize::from(size).height();
143        let between_space = match style {
144            ButtonGroupsStyle::Standard => match size {
145                ButtonGroupsSize::ExtraSmall => Dp(18.0),
146                ButtonGroupsSize::Small => Dp(12.0),
147                _ => Dp(8.0),
148            },
149            ButtonGroupsStyle::Connected => Dp(2.0),
150        };
151        let active_button_shape = match style {
152            ButtonGroupsStyle::Standard if shape == ButtonShape::Round => {
153                square(ButtonSize::from(size).square_radius())
154            }
155            ButtonGroupsStyle::Standard => Shape::CAPSULE,
156            ButtonGroupsStyle::Connected => Shape::CAPSULE,
157        };
158        let inner_radius = match size {
159            ButtonGroupsSize::Large => Dp(16.0),
160            ButtonGroupsSize::ExtraLarge => Dp(20.0),
161            _ => Dp(8.0),
162        };
163        let outer_corner = if shape == ButtonShape::Round {
164            RoundedCorner::Capsule
165        } else {
166            // The square connected outer corner is 4dp for extra small groups,
167            // while their inner corner token stays at 8dp.
168            let radius = match size {
169                ButtonGroupsSize::ExtraSmall => Dp(4.0),
170                _ => inner_radius,
171            };
172            RoundedCorner::manual(radius, 2.0)
173        };
174        let inactive_button_shape = match style {
175            ButtonGroupsStyle::Standard if shape == ButtonShape::Square => {
176                square(ButtonSize::from(size).square_radius())
177            }
178            ButtonGroupsStyle::Standard => Shape::CAPSULE,
179            ButtonGroupsStyle::Connected => square(inner_radius),
180        };
181        let inactive_button_shape_start = match style {
182            ButtonGroupsStyle::Standard => inactive_button_shape,
183            ButtonGroupsStyle::Connected => Shape::RoundedRectangle {
184                top_left: outer_corner,
185                top_right: RoundedCorner::manual(inner_radius, 2.0),
186                bottom_right: RoundedCorner::manual(inner_radius, 2.0),
187                bottom_left: outer_corner,
188            },
189        };
190        let inactive_button_shape_end = match style {
191            ButtonGroupsStyle::Standard => inactive_button_shape,
192            ButtonGroupsStyle::Connected => Shape::RoundedRectangle {
193                top_left: RoundedCorner::manual(inner_radius, 2.0),
194                top_right: outer_corner,
195                bottom_right: outer_corner,
196                bottom_left: RoundedCorner::manual(inner_radius, 2.0),
197            },
198        };
199        Self {
200            container_height,
201            between_space,
202            active_button_shape,
203            inactive_button_shape,
204            inactive_button_shape_start,
205            inactive_button_shape_end,
206            outer_corner,
207        }
208    }
209}
210
211impl From<ButtonGroupsSize> for ButtonSize {
212    fn from(size: ButtonGroupsSize) -> Self {
213        match size {
214            ButtonGroupsSize::ExtraSmall => Self::ExtraSmall,
215            ButtonGroupsSize::Small => Self::Small,
216            ButtonGroupsSize::Medium => Self::Medium,
217            ButtonGroupsSize::Large => Self::Large,
218            ButtonGroupsSize::ExtraLarge => Self::ExtraLarge,
219        }
220    }
221}
222
223#[derive(PartialEq, Default)]
224struct ButtonItemState {
225    actived: bool,
226}
227
228struct ButtonGroupsSemantics;
229
230impl SemanticsModifierNode for ButtonGroupsSemantics {
231    fn apply(
232        &self,
233        node: &mut AccessibilityNode,
234        _handler: &mut Option<AccessibilityActionHandler>,
235    ) {
236        node.role = Some(Role::Group);
237        node.orientation = Some(Orientation::Horizontal);
238        node.merge_descendants = true;
239    }
240}
241
242/// Selection shared with a button group.
243///
244/// Item indices follow visual order. Single-selection groups retain the first
245/// selected item when multiple selections are supplied.
246#[derive(PartialEq, Default)]
247pub struct ButtonGroupsState {
248    item_states: HashMap<usize, ButtonItemState>,
249}
250
251impl ButtonGroupsState {
252    /// Creates an empty selection.
253    pub fn new() -> Self {
254        Self::default()
255    }
256
257    /// Returns whether the item at `index` is selected.
258    pub fn is_selected(&self, index: usize) -> bool {
259        self.item_states
260            .get(&index)
261            .is_some_and(|item| item.actived)
262    }
263
264    /// Updates an item's selection without invoking its click callback.
265    pub fn set_selected(&mut self, index: usize, selected: bool) {
266        if self.is_selected(index) != selected {
267            let item = self.item_state_mut(index);
268            item.actived = selected;
269        }
270    }
271
272    /// Clears selection without invoking click callbacks.
273    /// Groups requiring selection restore the first enabled item after an empty
274    /// update.
275    pub fn clear(&mut self) {
276        for item in self.item_states.values_mut() {
277            if item.actived {
278                item.actived = false;
279            }
280        }
281    }
282
283    fn item_state_mut(&mut self, index: usize) -> &mut ButtonItemState {
284        self.item_states.entry(index).or_default()
285    }
286}
287
288/// # button_groups
289///
290/// Button groups organize buttons and add interactions between them.
291///
292/// ## Usage
293///
294/// Used for grouping related actions.
295///
296/// ## Parameters
297///
298/// - `size` — size of the button group.
299/// - `style` — visual style of the button group.
300/// - `variant` — color style of the buttons: filled (default), tonal, outlined,
301///   or elevated. Text buttons have no container treatment and use filled.
302/// - `presentation_states` — optional static visual interaction per item, in
303///   visual order, without changing input or focus. Pressed items also show the
304///   pressed shape.
305/// - `shape_style` — round or square resting shapes; defaults to round.
306/// - `expanded_ratio` — pressed width expansion in standard groups, clamped to
307///   0–1; defaults to 0.15. Neighbors limit expansion to their content padding.
308///   Non-finite values use the default.
309/// - `selection_mode` — single or multiple selection; switching to single
310///   selection retains the first selected item in visual order.
311/// - `modifier` — group sizing, placement, and semantics.
312/// - `state` — optional external selection; omitted groups remember their own.
313/// - `enabled` — whether the group accepts input.
314/// - `disabled_indices` — items that retain selection but cannot be activated.
315/// - `selection_required` — prevents removing the final selection; an empty
316///   selection chooses the first enabled item when available.
317/// - `child_closures` — per-item content builders.
318/// - `on_click_closures` — per-item click handlers receiving the new active
319///   state after selection has been updated.
320///
321/// ## Examples
322///
323/// ```
324/// use tessera_components::{
325///     button_groups::{ButtonGroupsState, button_groups},
326///     text::text,
327/// };
328///
329/// let mut selection = ButtonGroupsState::new();
330/// selection.set_selected(0, true);
331/// assert!(selection.is_selected(0));
332/// selection.clear();
333/// assert!(!selection.is_selected(0));
334///
335/// # use tessera_ui::tessera;
336/// # #[tessera]
337/// # fn demo() {
338/// button_groups().child(
339///     || {
340///         text("Button 1");
341///     },
342///     |_| {},
343/// );
344/// # }
345/// ```
346#[tessera]
347pub fn button_groups(
348    size: Option<ButtonGroupsSize>,
349    style: Option<ButtonGroupsStyle>,
350    variant: Option<ButtonVariant>,
351    presentation_states: Option<Vec<InteractionVisualState>>,
352    shape_style: Option<ButtonShape>,
353    expanded_ratio: Option<f32>,
354    selection_mode: Option<ButtonGroupsSelectionMode>,
355    modifier: Option<Modifier>,
356    state: Option<State<ButtonGroupsState>>,
357    enabled: Option<bool>,
358    disabled_indices: Option<Vec<usize>>,
359    selection_required: Option<bool>,
360    #[prop(skip_setter)] child_closures: Option<Vec<RenderSlot>>,
361    #[prop(skip_setter)] on_click_closures: Option<Vec<CallbackWith<bool>>>,
362) {
363    let size = size.unwrap_or_default();
364    let style = style.unwrap_or_default();
365    let variant = match variant.unwrap_or_default() {
366        ButtonVariant::Text => ButtonVariant::Filled,
367        variant => variant,
368    };
369    let presentation_states = presentation_states.unwrap_or_default();
370    let shape_style = shape_style.unwrap_or_default();
371    let expanded_ratio = expanded_ratio
372        .filter(|value| value.is_finite())
373        .unwrap_or(0.15)
374        .clamp(0.0, 1.0);
375    let selection_mode = selection_mode.unwrap_or_default();
376    let enabled = enabled.unwrap_or(true);
377    let disabled_indices = disabled_indices.unwrap_or_default();
378    let selection_required = selection_required.unwrap_or(false);
379    let child_closures = child_closures.unwrap_or_default();
380    let on_click_closures = on_click_closures.unwrap_or_default();
381    let state = if let Some(state) = state {
382        state
383    } else {
384        remember(ButtonGroupsState::default)
385    };
386    let metrics = ButtonGroupsLayout::new(size, style, shape_style);
387    let child_len = child_closures.len();
388    let policy = (selection_mode, selection_required);
389    let selection_policy = remember(|| policy);
390    if selection_policy.get() != policy {
391        selection_policy.set(policy);
392    }
393    if state.with(|state| state.item_states.keys().any(|index| *index >= child_len)) {
394        state.with_mut(|state| state.item_states.retain(|index, _| *index < child_len));
395    }
396    if selection_mode == ButtonGroupsSelectionMode::Single
397        && state.with(|state| {
398            state
399                .item_states
400                .values()
401                .filter(|item| item.actived)
402                .count()
403                > 1
404        })
405    {
406        state.with_mut(|state| {
407            let selected = state
408                .item_states
409                .iter()
410                .filter(|(_, item)| item.actived)
411                .map(|(index, _)| *index)
412                .min();
413            for (index, item) in &mut state.item_states {
414                if item.actived && Some(*index) != selected {
415                    item.actived = false;
416                }
417            }
418        });
419    }
420    if selection_required
421        && enabled
422        && !state.with(|state| state.item_states.values().any(|item| item.actived))
423        && let Some(index) = (0..child_len).find(|index| !disabled_indices.contains(index))
424    {
425        state.with_mut(|state| state.set_selected(index, true));
426    }
427    layout()
428        .modifier(
429            modifier
430                .unwrap_or_default()
431                .focus_group()
432                .focus_traversal_policy(FocusTraversalPolicy::horizontal().wrap(true))
433                .push_semantics(ButtonGroupsSemantics)
434                .size_in(
435                    None,
436                    None,
437                    Some(Dp(metrics.container_height.0.max(48.0))),
438                    None,
439                ),
440        )
441        .policy(ButtonGroupsRowLayout {
442            connected: style == ButtonGroupsStyle::Connected,
443            spacing: metrics.between_space.into(),
444            expanded_ratio,
445        })
446        .child(move || {
447            for (index, child_closure) in child_closures.iter().cloned().enumerate() {
448                key(index, || {
449                    let on_click_closure = on_click_closures[index];
450                    let item_layout = metrics.clone();
451                    let enabled = enabled && !disabled_indices.contains(&index);
452                    let presentation = presentation_states.get(index).copied();
453
454                    let interaction = remember(InteractionState::new);
455                    let width_motion = remember(PressWidthMotion::default);
456                    let item = RenderSlot::new(move || {
457                        let actived = state.with(|state| state.is_selected(index));
458                        let pressed = enabled
459                            && (presentation == Some(InteractionVisualState::Pressed)
460                                || interaction.with(InteractionState::is_pressed));
461                        let resting_shape = if actived {
462                            item_layout.active_button_shape
463                        } else if child_len == 1 && style == ButtonGroupsStyle::Connected {
464                            let corner = item_layout.outer_corner;
465                            Shape::RoundedRectangle {
466                                top_left: corner,
467                                top_right: corner,
468                                bottom_right: corner,
469                                bottom_left: corner,
470                            }
471                        } else if index == 0 {
472                            item_layout.inactive_button_shape_start
473                        } else if index == child_len - 1 {
474                            item_layout.inactive_button_shape_end
475                        } else {
476                            item_layout.inactive_button_shape
477                        };
478                        let shape = if pressed {
479                            if style == ButtonGroupsStyle::Connected && child_len > 1 {
480                                let radius = match size {
481                                    ButtonGroupsSize::Large => Dp(12.0),
482                                    ButtonGroupsSize::ExtraLarge => Dp(16.0),
483                                    _ => Dp(4.0),
484                                };
485                                let inner = RoundedCorner::manual(radius, 2.0);
486                                Shape::RoundedRectangle {
487                                    top_left: if index == 0 {
488                                        item_layout.outer_corner
489                                    } else {
490                                        inner
491                                    },
492                                    top_right: if index + 1 == child_len {
493                                        item_layout.outer_corner
494                                    } else {
495                                        inner
496                                    },
497                                    bottom_right: if index + 1 == child_len {
498                                        item_layout.outer_corner
499                                    } else {
500                                        inner
501                                    },
502                                    bottom_left: if index == 0 {
503                                        item_layout.outer_corner
504                                    } else {
505                                        inner
506                                    },
507                                }
508                            } else {
509                                let corner = RoundedCorner::manual(
510                                    ButtonSize::from(size).pressed_radius(),
511                                    2.0,
512                                );
513                                Shape::RoundedRectangle {
514                                    top_left: corner,
515                                    top_right: corner,
516                                    bottom_right: corner,
517                                    bottom_left: corner,
518                                }
519                            }
520                        } else {
521                            resting_shape
522                        };
523                        let motion = remember(|| ShapeMotion::new(shape));
524                        let mut next = motion.get();
525                        next.retarget(shape, enabled, current_frame_nanos());
526                        if next != motion.get() {
527                            motion.set(next);
528                        }
529                        if next.is_animating() {
530                            receive_frame_nanos(move |frame| {
531                                let mut next = motion.get();
532                                next.advance(frame);
533                                if next != motion.get() {
534                                    motion.set(next);
535                                }
536                                if next.is_animating() {
537                                    tessera_ui::FrameNanosControl::Continue
538                                } else {
539                                    tessera_ui::FrameNanosControl::Stop
540                                }
541                            });
542                        }
543                        button()
544                            .variant(variant)
545                            .presentation_state_optional(presentation)
546                            .size(size.into())
547                            .content_padding(Padding::default())
548                            .selected(actived)
549                            .enabled(enabled)
550                            .accessibility_role(Role::CheckBox)
551                            .interaction_state(interaction)
552                            .on_click(move || {
553                                let mut next = width_motion.get();
554                                next.pulse(current_frame_nanos());
555                                if next != width_motion.get() {
556                                    width_motion.set(next);
557                                }
558                                let active = state.with(|state| {
559                                    state
560                                        .item_states
561                                        .get(&index)
562                                        .is_some_and(|item| item.actived)
563                                });
564                                let (mode, required) = selection_policy.get();
565                                if active
566                                    && required
567                                    && state.with(|state| {
568                                        state
569                                            .item_states
570                                            .values()
571                                            .filter(|item| item.actived)
572                                            .count()
573                                            == 1
574                                    })
575                                {
576                                    return;
577                                }
578                                state.with_mut(|s| {
579                                    if !active && mode == ButtonGroupsSelectionMode::Single {
580                                        for (other_index, item) in &mut s.item_states {
581                                            if *other_index != index && item.actived {
582                                                item.actived = false;
583                                            }
584                                        }
585                                    }
586
587                                    let item = s.item_state_mut(index);
588                                    item.actived = !active;
589                                });
590                                on_click_closure.call(!active);
591                            })
592                            .shape(motion.get().shape())
593                            .child(move || {
594                                let icon_size = ButtonSize::from(size).icon_size();
595                                provide_context(
596                                    || IconSize(icon_size),
597                                    move || {
598                                        let inherited = use_context::<IconSize>()
599                                            .expect("button group icon size");
600                                        if inherited.get().0 != icon_size {
601                                            inherited.set(IconSize(icon_size));
602                                        }
603                                        layout()
604                                            .policy(GroupContentLayout {
605                                                padding: ButtonSize::from(size).content_padding(),
606                                                compress: style == ButtonGroupsStyle::Standard,
607                                            })
608                                            .child_shared(child_closure);
609                                    },
610                                );
611                            });
612                    });
613                    group_item()
614                        .interaction_state(interaction)
615                        .width_motion(width_motion)
616                        .presented_pressed(presentation == Some(InteractionVisualState::Pressed))
617                        .enabled(enabled && expanded_ratio > 0.0 && child_len > 1)
618                        .compression_limit(ButtonSize::from(size).content_padding().right)
619                        .connected(style == ButtonGroupsStyle::Connected)
620                        .child_shared(item);
621                });
622            }
623        });
624}
625
626#[tessera]
627fn group_item(
628    interaction_state: Option<State<InteractionState>>,
629    width_motion: Option<State<PressWidthMotion>>,
630    presented_pressed: Option<bool>,
631    enabled: Option<bool>,
632    compression_limit: Option<Dp>,
633    connected: Option<bool>,
634    child: Option<RenderSlot>,
635) {
636    let interaction = interaction_state.expect("group item interaction");
637    let presented_pressed = presented_pressed.unwrap_or(false);
638    let motion = width_motion.expect("group item width motion");
639    let child = child.expect("group item content");
640    let mut next = motion.get();
641    next.update(
642        presented_pressed || interaction.with(InteractionState::is_pressed),
643        enabled.unwrap_or(true) && !connected.unwrap_or(false),
644        current_frame_nanos(),
645    );
646    if next != motion.get() {
647        motion.set(next);
648    }
649    if next.is_animating() {
650        receive_frame_nanos(move |frame| {
651            let mut next = motion.get();
652            next.advance(frame);
653            if next != motion.get() {
654                motion.set(next);
655            }
656            if next.is_animating() {
657                tessera_ui::FrameNanosControl::Continue
658            } else {
659                tessera_ui::FrameNanosControl::Stop
660            }
661        });
662    }
663    layout()
664        .modifier(
665            Modifier::new()
666                .push_parent_data(WidthData {
667                    progress: motion.get().value(),
668                    compression_limit: compression_limit.unwrap_or_default().into(),
669                })
670                .push_pointer_input(WidthObserver {
671                    interaction,
672                    motion,
673                    presented_pressed,
674                }),
675        )
676        .policy(GroupItemLayout)
677        .child(move || {
678            child.render();
679        });
680}
681
682#[derive(Clone, PartialEq)]
683struct GroupItemLayout;
684
685impl tessera_ui::LayoutModifierNode for GroupItemLayout {
686    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
687        let child = input.children()[0];
688        let constraint = *input.parent_constraint().as_ref();
689        let size = child.measure(input, &constraint)?.size();
690        let mut result = LayoutResult::new(size);
691        result.place_child(child, PxPosition::ZERO);
692        Ok(result)
693    }
694}
695
696#[derive(Clone, PartialEq)]
697struct GroupContentLayout {
698    padding: Padding,
699    compress: bool,
700}
701
702impl tessera_ui::LayoutModifierNode for GroupContentLayout {
703    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
704        let parent = *input.parent_constraint().as_ref();
705        let children = input.children();
706        let mut horizontal = Px::from(self.padding.left) + Px::from(self.padding.right);
707        let top = Px::from(self.padding.top);
708        let vertical = top + Px::from(self.padding.bottom);
709        if self.compress {
710            let mut natural_width = Px::ZERO;
711            for child in &children {
712                natural_width =
713                    natural_width.max(child.probe_intrinsic(input, &Constraint::NONE)?.width);
714            }
715            if let Some(maximum) = parent.width.resolve_max() {
716                horizontal = horizontal.min((maximum - natural_width).max(Px::ZERO));
717            }
718        }
719        let constraint = Constraint::new(
720            AxisConstraint::new(
721                Px::ZERO,
722                parent
723                    .width
724                    .resolve_max()
725                    .map(|width| (width - horizontal).max(Px::ZERO)),
726            ),
727            AxisConstraint::new(
728                Px::ZERO,
729                parent
730                    .height
731                    .resolve_max()
732                    .map(|height| (height - vertical).max(Px::ZERO)),
733            ),
734        );
735        let mut content = ComputedData {
736            width: Px::ZERO,
737            height: Px::ZERO,
738        };
739        for child in &children {
740            let size = child.measure(input, &constraint)?;
741            content.width = content.width.max(size.width);
742            content.height = content.height.max(size.height);
743        }
744        let size = ComputedData {
745            width: parent.width.clamp(content.width + horizontal),
746            height: parent.height.clamp(content.height + vertical),
747        };
748        let left = if self.compress {
749            horizontal.0 as f32 / 2.0
750        } else {
751            Px::from(self.padding.left).0 as f32
752        };
753        let mut result = LayoutResult::new(size);
754        for child in children {
755            result.place_child(child, PxPosition::from_f32_xy(left, top.0 as f32));
756        }
757        Ok(result)
758    }
759}
760
761#[derive(Clone, PartialEq)]
762struct ButtonGroupsRowLayout {
763    connected: bool,
764    spacing: Px,
765    expanded_ratio: f32,
766}
767
768impl tessera_ui::LayoutModifierNode for ButtonGroupsRowLayout {
769    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
770        let children = input.children();
771        let parent = *input.parent_constraint().as_ref();
772        let mut result = LayoutResult::default();
773        if children.is_empty() {
774            return Ok(result.with_size(ComputedData {
775                width: parent.width.clamp(Px::ZERO),
776                height: parent.height.clamp(Px::ZERO),
777            }));
778        }
779        let count = children.len() as i32;
780        let spacing = if count > 1 {
781            self.spacing
782                .min(parent.width.resolve_max().unwrap_or(Px::MAX) / (count - 1))
783        } else {
784            Px::ZERO
785        };
786        let gaps = spacing * (count - 1);
787        let child_constraint =
788            Constraint::new(parent.width.without_min(), parent.height.without_min());
789        let mut sizes = Vec::with_capacity(children.len());
790        for child in &children {
791            sizes.push(child.measure(input, &child_constraint)?.size());
792        }
793        if self.connected {
794            let natural = sizes
795                .iter()
796                .map(|size| size.width)
797                .max()
798                .unwrap_or(Px::ZERO)
799                * count
800                + gaps;
801            let budget = (parent.width.clamp(natural) - gaps).max(Px::ZERO);
802            let width = budget / count;
803            let remainder = budget.0 % count;
804            for (index, child) in children.iter().enumerate() {
805                let width = width + Px(i32::from((index as i32) < remainder));
806                sizes[index] = child
807                    .measure(
808                        input,
809                        &Constraint::new(AxisConstraint::exact(width), child_constraint.height),
810                    )?
811                    .size();
812            }
813        } else {
814            let data: Vec<_> = children
815                .iter()
816                .map(|child| child.parent_data::<WidthData>(input).unwrap_or_default())
817                .collect();
818            let mut widths: Vec<_> = sizes.iter().map(|size| size.width).collect();
819            redistribute_widths(&mut widths, &data, self.expanded_ratio);
820            for (index, child) in children.iter().enumerate() {
821                if widths[index] != sizes[index].width {
822                    sizes[index] = child
823                        .measure(
824                            input,
825                            &Constraint::new(
826                                AxisConstraint::exact(widths[index]),
827                                child_constraint.height,
828                            ),
829                        )?
830                        .size();
831                }
832            }
833        }
834        let height = sizes
835            .iter()
836            .map(|size| size.height)
837            .max()
838            .unwrap_or(Px::ZERO);
839        if self.connected {
840            let height = parent.height.clamp(height);
841            for (index, child) in children.iter().enumerate() {
842                if sizes[index].height == height {
843                    continue;
844                }
845                sizes[index] = child
846                    .measure(input, &Constraint::new(sizes[index].width, height))?
847                    .size();
848            }
849        }
850        let size = ComputedData {
851            width: parent.width.clamp(
852                sizes
853                    .iter()
854                    .map(|size| size.width)
855                    .fold(gaps, |total, width| total + width),
856            ),
857            height: parent.height.clamp(height),
858        };
859        let mut x = Px::ZERO;
860        for (child, child_size) in children.iter().zip(sizes) {
861            result.place_child(
862                *child,
863                PxPosition::from_f32_xy(
864                    x.0 as f32,
865                    (size.height.0 - child_size.height.0) as f32 / 2.0,
866                ),
867            );
868            x += child_size.width + spacing;
869        }
870        Ok(result.with_size(size))
871    }
872}
873
874#[cfg(test)]
875mod tests;