1use 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#[derive(Debug, Clone, PartialEq, Copy, Default)]
40pub enum ButtonGroupsStyle {
41 #[default]
43 Standard,
44 Connected,
46}
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51pub enum ButtonGroupsSelectionMode {
52 #[default]
54 Single,
55 Multiple,
57}
58
59#[derive(Debug, Clone, PartialEq, Copy, Default)]
62pub enum ButtonGroupsSize {
63 ExtraSmall,
65 Small,
67 #[default]
69 Medium,
70 Large,
72 ExtraLarge,
74}
75
76impl ButtonGroupsBuilder {
77 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 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 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#[derive(PartialEq, Default)]
247pub struct ButtonGroupsState {
248 item_states: HashMap<usize, ButtonItemState>,
249}
250
251impl ButtonGroupsState {
252 pub fn new() -> Self {
254 Self::default()
255 }
256
257 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 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 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#[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;