1use tessera_ui::{
8 AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, LayoutResult, MeasurementError,
9 Modifier, Px, PxPosition, RenderSlot, State,
10 layout::{MeasureScope, layout},
11 tessera, use_context,
12};
13
14use crate::{
15 button::ButtonDefaults,
16 modifier::{InteractionState, Padding},
17 shape_def::{RoundedCorner, Shape},
18 theme::{MaterialColorScheme, MaterialTheme, TextStyle},
19};
20
21use item::split_button_item;
22
23mod item;
24
25const CORNER_G2_VALUE: f32 = 2.0;
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum SplitButtonSize {
30 ExtraSmall,
32 #[default]
34 Small,
35 Medium,
37 Large,
39 ExtraLarge,
41}
42
43impl SplitButtonSize {
44 fn text_style(self, theme: &MaterialTheme) -> TextStyle {
45 match self {
46 Self::ExtraSmall | Self::Small => theme.typography.label_large,
47 Self::Medium => theme.typography.title_medium,
48 Self::Large => theme.typography.headline_small,
49 Self::ExtraLarge => theme.typography.headline_large,
50 }
51 }
52}
53
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub enum SplitButtonVariant {
57 #[default]
59 Filled,
60 Tonal,
62 Elevated,
64 Outlined,
66}
67
68#[derive(Clone, PartialEq, Copy, Debug)]
70pub struct SplitButtonColors {
71 pub container_color: Color,
73 pub content_color: Color,
75 pub border_color: Color,
77 pub disabled_container_color: Color,
79 pub disabled_content_color: Color,
81 pub disabled_border_color: Color,
83}
84
85impl SplitButtonColors {
86 fn container_color(self, enabled: bool) -> Color {
87 if enabled {
88 self.container_color
89 } else {
90 self.disabled_container_color
91 }
92 }
93
94 fn content_color(self, enabled: bool) -> Color {
95 if enabled {
96 self.content_color
97 } else {
98 self.disabled_content_color
99 }
100 }
101
102 fn border_color(self, enabled: bool) -> Color {
103 if enabled {
104 self.border_color
105 } else {
106 self.disabled_border_color
107 }
108 }
109}
110
111pub struct SplitButtonDefaults;
113
114impl SplitButtonDefaults {
115 pub const MIN_WIDTH: Dp = Dp(48.0);
117 pub const SPACING: Dp = Dp(2.0);
119 pub const LEADING_ICON_SIZE: Dp = Dp(20.0);
121
122 pub const fn leading_icon_size(size: SplitButtonSize) -> Dp {
124 Dp(match size {
125 SplitButtonSize::ExtraSmall | SplitButtonSize::Small => 20.0,
126 SplitButtonSize::Medium => 24.0,
127 SplitButtonSize::Large => 32.0,
128 SplitButtonSize::ExtraLarge => 40.0,
129 })
130 }
131
132 pub const fn icon_spacing(size: SplitButtonSize) -> Dp {
134 Dp(match size {
135 SplitButtonSize::ExtraSmall | SplitButtonSize::Small | SplitButtonSize::Medium => 8.0,
136 SplitButtonSize::Large => 12.0,
137 SplitButtonSize::ExtraLarge => 16.0,
138 })
139 }
140
141 pub fn container_height(size: SplitButtonSize) -> Dp {
143 match size {
144 SplitButtonSize::ExtraSmall => Dp(32.0),
145 SplitButtonSize::Small => Dp(40.0),
146 SplitButtonSize::Medium => Dp(56.0),
147 SplitButtonSize::Large => Dp(96.0),
148 SplitButtonSize::ExtraLarge => Dp(136.0),
149 }
150 }
151
152 pub fn inner_corner_size(size: SplitButtonSize) -> Dp {
154 match size {
155 SplitButtonSize::ExtraSmall => Dp(4.0),
156 SplitButtonSize::Small => Dp(4.0),
157 SplitButtonSize::Medium => Dp(4.0),
158 SplitButtonSize::Large => Dp(8.0),
159 SplitButtonSize::ExtraLarge => Dp(12.0),
160 }
161 }
162
163 pub const fn active_inner_corner_size(size: SplitButtonSize) -> Dp {
165 Dp(match size {
166 SplitButtonSize::ExtraSmall => 8.0,
167 SplitButtonSize::Small | SplitButtonSize::Medium => 12.0,
168 SplitButtonSize::Large | SplitButtonSize::ExtraLarge => 20.0,
169 })
170 }
171
172 pub const fn trailing_optical_offset(size: SplitButtonSize) -> Dp {
175 Dp(match size {
176 SplitButtonSize::ExtraSmall | SplitButtonSize::Small => -1.0,
177 SplitButtonSize::Medium => -2.0,
178 SplitButtonSize::Large => -3.0,
179 SplitButtonSize::ExtraLarge => -6.0,
180 })
181 }
182
183 pub fn leading_content_padding(size: SplitButtonSize) -> Padding {
185 let (start, end) = match size {
186 SplitButtonSize::ExtraSmall => (Dp(12.0), Dp(10.0)),
187 SplitButtonSize::Small => (Dp(16.0), Dp(12.0)),
188 SplitButtonSize::Medium => (Dp(24.0), Dp(24.0)),
189 SplitButtonSize::Large => (Dp(48.0), Dp(48.0)),
190 SplitButtonSize::ExtraLarge => (Dp(64.0), Dp(64.0)),
191 };
192 Padding::new(start, Dp(0.0), end, Dp(0.0))
193 }
194
195 pub fn trailing_content_padding(size: SplitButtonSize) -> Padding {
197 let (start, end) = match size {
198 SplitButtonSize::ExtraSmall => (Dp(13.0), Dp(13.0)),
199 SplitButtonSize::Small => (Dp(13.0), Dp(13.0)),
200 SplitButtonSize::Medium => (Dp(15.0), Dp(15.0)),
201 SplitButtonSize::Large => (Dp(29.0), Dp(29.0)),
202 SplitButtonSize::ExtraLarge => (Dp(43.0), Dp(43.0)),
203 };
204 Padding::new(start, Dp(0.0), end, Dp(0.0))
205 }
206
207 pub fn trailing_icon_size(size: SplitButtonSize) -> Dp {
209 match size {
210 SplitButtonSize::ExtraSmall => Dp(22.0),
211 SplitButtonSize::Small => Dp(22.0),
212 SplitButtonSize::Medium => Dp(26.0),
213 SplitButtonSize::Large => Dp(38.0),
214 SplitButtonSize::ExtraLarge => Dp(50.0),
215 }
216 }
217
218 pub const fn trailing_min_width(size: SplitButtonSize) -> Dp {
220 Dp(match size {
221 SplitButtonSize::ExtraSmall | SplitButtonSize::Small => 48.0,
222 SplitButtonSize::Medium => 56.0,
223 SplitButtonSize::Large => 96.0,
224 SplitButtonSize::ExtraLarge => 136.0,
225 })
226 }
227
228 pub fn leading_shape(size: SplitButtonSize) -> Shape {
230 let inner = RoundedCorner::manual(Self::inner_corner_size(size), CORNER_G2_VALUE);
231 Shape::RoundedRectangle {
232 top_left: RoundedCorner::Capsule,
233 top_right: inner,
234 bottom_right: inner,
235 bottom_left: RoundedCorner::Capsule,
236 }
237 }
238
239 pub fn trailing_shape(size: SplitButtonSize) -> Shape {
241 let inner = RoundedCorner::manual(Self::inner_corner_size(size), CORNER_G2_VALUE);
242 Shape::RoundedRectangle {
243 top_left: inner,
244 top_right: RoundedCorner::Capsule,
245 bottom_right: RoundedCorner::Capsule,
246 bottom_left: inner,
247 }
248 }
249
250 pub fn colors(scheme: &MaterialColorScheme, variant: SplitButtonVariant) -> SplitButtonColors {
252 let scheme = scheme.clone();
253
254 match variant {
255 SplitButtonVariant::Filled => SplitButtonColors {
256 container_color: scheme.primary,
257 content_color: scheme.on_primary,
258 border_color: scheme.primary,
259 disabled_container_color: scheme
260 .on_surface
261 .with_alpha(ButtonDefaults::FILLED_DISABLED_CONTAINER_ALPHA),
262 disabled_content_color: scheme
263 .on_surface_variant
264 .with_alpha(ButtonDefaults::DISABLED_LABEL_ALPHA),
265 disabled_border_color: ButtonDefaults::disabled_border_color(&scheme),
266 },
267 SplitButtonVariant::Tonal => SplitButtonColors {
268 container_color: scheme.secondary_container,
269 content_color: scheme.on_secondary_container,
270 border_color: scheme.secondary_container,
271 disabled_container_color: scheme
272 .on_surface
273 .with_alpha(ButtonDefaults::FILLED_DISABLED_CONTAINER_ALPHA),
274 disabled_content_color: scheme
275 .on_surface_variant
276 .with_alpha(ButtonDefaults::DISABLED_LABEL_ALPHA),
277 disabled_border_color: ButtonDefaults::disabled_border_color(&scheme),
278 },
279 SplitButtonVariant::Elevated => SplitButtonColors {
280 container_color: scheme.surface_container_low,
281 content_color: scheme.primary,
282 border_color: scheme.surface_container_low,
283 disabled_container_color: scheme
284 .on_surface
285 .with_alpha(ButtonDefaults::FILLED_DISABLED_CONTAINER_ALPHA),
286 disabled_content_color: scheme
287 .on_surface_variant
288 .with_alpha(ButtonDefaults::DISABLED_LABEL_ALPHA),
289 disabled_border_color: ButtonDefaults::disabled_border_color(&scheme),
290 },
291 SplitButtonVariant::Outlined => SplitButtonColors {
292 container_color: Color::TRANSPARENT,
293 content_color: scheme.on_surface_variant,
294 border_color: scheme.outline_variant,
295 disabled_container_color: Color::TRANSPARENT,
296 disabled_content_color: scheme
297 .on_surface_variant
298 .with_alpha(ButtonDefaults::DISABLED_LABEL_ALPHA),
299 disabled_border_color: scheme
300 .outline_variant
301 .with_alpha(ButtonDefaults::FILLED_DISABLED_CONTAINER_ALPHA),
302 },
303 }
304 }
305
306 pub fn border_width(variant: SplitButtonVariant) -> Dp {
308 match variant {
309 SplitButtonVariant::Outlined => Dp(1.0),
310 _ => Dp(0.0),
311 }
312 }
313
314 pub const fn border_width_for_size(variant: SplitButtonVariant, size: SplitButtonSize) -> Dp {
316 if !matches!(variant, SplitButtonVariant::Outlined) {
317 return Dp(0.0);
318 }
319 Dp(match size {
320 SplitButtonSize::ExtraSmall | SplitButtonSize::Small | SplitButtonSize::Medium => 1.0,
321 SplitButtonSize::Large => 2.0,
322 SplitButtonSize::ExtraLarge => 3.0,
323 })
324 }
325
326 pub fn elevation(variant: SplitButtonVariant) -> Option<Dp> {
328 match variant {
329 SplitButtonVariant::Elevated => Some(Dp(1.0)),
330 _ => None,
331 }
332 }
333
334 pub fn tonal_elevation(_variant: SplitButtonVariant) -> Dp {
336 Dp(0.0)
337 }
338}
339
340#[allow(missing_docs)]
341impl SplitLeadingButtonBuilder {
342 pub fn filled(&self) -> Self {
343 self.variant(SplitButtonVariant::Filled)
344 }
345
346 pub fn tonal(&self) -> Self {
347 self.variant(SplitButtonVariant::Tonal)
348 }
349
350 pub fn elevated(&self) -> Self {
351 self.variant(SplitButtonVariant::Elevated)
352 }
353
354 pub fn outlined(&self) -> Self {
355 self.variant(SplitButtonVariant::Outlined)
356 }
357}
358
359#[allow(missing_docs)]
360impl SplitTrailingButtonBuilder {
361 pub fn filled(&self) -> Self {
362 self.variant(SplitButtonVariant::Filled)
363 }
364
365 pub fn tonal(&self) -> Self {
366 self.variant(SplitButtonVariant::Tonal)
367 }
368
369 pub fn elevated(&self) -> Self {
370 self.variant(SplitButtonVariant::Elevated)
371 }
372
373 pub fn outlined(&self) -> Self {
374 self.variant(SplitButtonVariant::Outlined)
375 }
376}
377
378#[tessera]
430pub fn split_button_layout(
431 modifier: Option<Modifier>,
432 spacing: Option<Dp>,
433 leading_button: Option<RenderSlot>,
434 trailing_button: Option<RenderSlot>,
435) {
436 let modifier = modifier.unwrap_or_default();
437 let leading_button = leading_button.unwrap_or_else(RenderSlot::empty);
438 let trailing_button = trailing_button.unwrap_or_else(RenderSlot::empty);
439 let spacing = Px::from(spacing.unwrap_or(SplitButtonDefaults::SPACING)).max(Px::ZERO);
440 layout()
441 .modifier(modifier)
442 .policy(SplitButton { spacing })
443 .child(move || {
444 leading_button.render();
445 trailing_button.render();
446 });
447}
448
449#[tessera]
494pub fn split_leading_button(
495 variant: Option<SplitButtonVariant>,
496 size: Option<SplitButtonSize>,
497 enabled: Option<bool>,
498 modifier: Option<Modifier>,
499 on_click: Option<Callback>,
500 #[prop(into)] accessibility_label: Option<String>,
501 #[prop(into)] accessibility_description: Option<String>,
502 interaction_state: Option<State<InteractionState>>,
503 shape: Option<Shape>,
504 content: Option<RenderSlot>,
505) {
506 let size = size.unwrap_or_default();
507 let scheme = use_context::<MaterialTheme>()
508 .expect("MaterialTheme must be provided")
509 .get()
510 .color_scheme;
511 let args = SplitButtonItemArgs::leading(
512 &scheme,
513 variant.unwrap_or_default(),
514 size,
515 enabled.unwrap_or(true),
516 modifier.unwrap_or_default(),
517 on_click,
518 accessibility_label,
519 accessibility_description,
520 );
521 split_button_item()
522 .args(args)
523 .size(size)
524 .leading(true)
525 .interaction_state_optional(interaction_state)
526 .shape_optional(shape)
527 .content_optional(content);
528}
529
530#[tessera]
577pub fn split_trailing_button(
578 variant: Option<SplitButtonVariant>,
579 size: Option<SplitButtonSize>,
580 enabled: Option<bool>,
581 modifier: Option<Modifier>,
582 on_click: Option<Callback>,
583 #[prop(into)] accessibility_label: Option<String>,
584 #[prop(into)] accessibility_description: Option<String>,
585 interaction_state: Option<State<InteractionState>>,
586 shape: Option<Shape>,
587 expanded: Option<bool>,
588 content: Option<RenderSlot>,
589) {
590 let size = size.unwrap_or_default();
591 let scheme = use_context::<MaterialTheme>()
592 .expect("MaterialTheme must be provided")
593 .get()
594 .color_scheme;
595 let args = SplitButtonItemArgs::trailing(
596 &scheme,
597 variant.unwrap_or_default(),
598 size,
599 enabled.unwrap_or(true),
600 modifier.unwrap_or_default(),
601 on_click,
602 accessibility_label,
603 accessibility_description,
604 );
605 split_button_item()
606 .args(args)
607 .size(size)
608 .leading(false)
609 .interaction_state_optional(interaction_state)
610 .shape_optional(shape)
611 .expanded_optional(expanded)
612 .content_optional(content);
613}
614
615#[derive(Clone, PartialEq)]
616struct SplitButtonItemArgs {
617 enabled: bool,
618 modifier: Modifier,
619 colors: SplitButtonColors,
620 border_width: Dp,
621 content_padding: Padding,
622 min_width: Dp,
623 container_height: Dp,
624 elevation: Option<Dp>,
625 tonal_elevation: Dp,
626 on_click: Option<Callback>,
627 accessibility_label: Option<String>,
628 accessibility_description: Option<String>,
629}
630
631impl SplitButtonItemArgs {
632 fn leading(
633 scheme: &MaterialColorScheme,
634 variant: SplitButtonVariant,
635 size: SplitButtonSize,
636 enabled: bool,
637 modifier: Modifier,
638 on_click: Option<Callback>,
639 accessibility_label: Option<String>,
640 accessibility_description: Option<String>,
641 ) -> Self {
642 Self {
643 enabled,
644 modifier,
645 colors: SplitButtonDefaults::colors(scheme, variant),
646 border_width: SplitButtonDefaults::border_width_for_size(variant, size),
647 content_padding: SplitButtonDefaults::leading_content_padding(size),
648 min_width: SplitButtonDefaults::MIN_WIDTH,
649 container_height: SplitButtonDefaults::container_height(size),
650 elevation: SplitButtonDefaults::elevation(variant),
651 tonal_elevation: SplitButtonDefaults::tonal_elevation(variant),
652 on_click,
653 accessibility_label,
654 accessibility_description,
655 }
656 }
657
658 fn trailing(
659 scheme: &MaterialColorScheme,
660 variant: SplitButtonVariant,
661 size: SplitButtonSize,
662 enabled: bool,
663 modifier: Modifier,
664 on_click: Option<Callback>,
665 accessibility_label: Option<String>,
666 accessibility_description: Option<String>,
667 ) -> Self {
668 Self {
669 enabled,
670 modifier,
671 colors: SplitButtonDefaults::colors(scheme, variant),
672 border_width: SplitButtonDefaults::border_width_for_size(variant, size),
673 content_padding: SplitButtonDefaults::trailing_content_padding(size),
674 min_width: SplitButtonDefaults::trailing_min_width(size),
675 container_height: SplitButtonDefaults::container_height(size),
676 elevation: SplitButtonDefaults::elevation(variant),
677 tonal_elevation: SplitButtonDefaults::tonal_elevation(variant),
678 on_click,
679 accessibility_label,
680 accessibility_description,
681 }
682 }
683}
684
685#[derive(Clone, PartialEq)]
686struct SplitButton {
687 spacing: Px,
688}
689
690impl tessera_ui::LayoutModifierNode for SplitButton {
691 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
692 let mut result = LayoutResult::default();
693 let children = input.children();
694 if children.len() != 2 {
695 return Err(MeasurementError::MeasureFnFailed(
696 "SplitButtonLayout requires exactly two children.".to_string(),
697 ));
698 }
699
700 let layout_constraint = *input.parent_constraint().as_ref();
701 let child_constraint = Constraint::new(
702 AxisConstraint::new(Px::ZERO, layout_constraint.width.resolve_max()),
703 AxisConstraint::new(Px::ZERO, layout_constraint.height.resolve_max()),
704 );
705
706 let trailing = children[1];
707 let trailing_size = trailing.probe(input, &child_constraint)?.size();
708 let spacing = layout_constraint
709 .width
710 .resolve_max()
711 .map_or(self.spacing, |max| {
712 self.spacing.min((max - trailing_size.width).max(Px::ZERO))
713 });
714 let leading_max_width = layout_constraint
715 .width
716 .resolve_max()
717 .map(|max| (max - trailing_size.width - spacing).max(Px::ZERO));
718 let leading_constraint = Constraint::new(
719 AxisConstraint::new(Px::ZERO, leading_max_width),
720 child_constraint.height,
721 );
722 let leading = children[0];
723 let leading_visual = leading.probe_intrinsic(input, &leading_constraint)?.size();
724 let trailing_visual = trailing.probe_intrinsic(input, &child_constraint)?.size();
725 let visual_height = child_constraint
726 .height
727 .clamp(leading_visual.height.max(trailing_visual.height));
728 let leading_constraint = Constraint::new(
729 leading_constraint.width,
730 AxisConstraint::new(visual_height, child_constraint.height.max),
731 );
732 let trailing_constraint =
733 Constraint::new(child_constraint.width, leading_constraint.height);
734 let leading_size = leading.measure(input, &leading_constraint)?.size();
735 let trailing_size = trailing.measure(input, &trailing_constraint)?.size();
736 let content_width = leading_size.width + trailing_size.width + spacing;
737 let content_height = leading_size.height.max(trailing_size.height);
738 let final_width = layout_constraint.width.clamp(content_width);
739 let final_height = layout_constraint.height.clamp(content_height);
740
741 result.place_child(
742 leading,
743 PxPosition::new(Px::ZERO, center_offset(leading_size.height, final_height)),
744 );
745 result.place_child(
746 trailing,
747 PxPosition::new(
748 leading_size.width + spacing,
749 center_offset(trailing_size.height, final_height),
750 ),
751 );
752
753 Ok(result.with_size(ComputedData {
754 width: final_width,
755 height: final_height,
756 }))
757 }
758}
759
760fn center_offset(child: Px, container: Px) -> Px {
761 if container.0 > child.0 {
762 (container - child) / 2
763 } else {
764 Px::ZERO
765 }
766}
767
768#[cfg(test)]
769mod expressive_tests;
770
771#[cfg(test)]
772mod height_tests;
773
774#[cfg(test)]
775mod tests {
776 use tessera_ui::{Modifier, tessera};
777
778 use crate::{
779 modifier::{ModifierExt as _, SemanticsArgs},
780 split_buttons::{
781 SplitButtonSize, split_button_layout, split_leading_button, split_trailing_button,
782 },
783 text::text,
784 theme::{MaterialTheme, material_theme},
785 };
786
787 #[tessera]
788 fn split_button_case() {
789 material_theme()
790 .theme(|| MaterialTheme::default())
791 .child(|| {
792 split_button_layout()
793 .leading_button(|| {
794 split_leading_button()
795 .filled()
796 .size(SplitButtonSize::Small)
797 .modifier(Modifier::new().semantics(SemanticsArgs {
798 test_tag: Some("split-leading".to_string()),
799 ..Default::default()
800 }))
801 .content(|| {
802 text("Send");
803 });
804 })
805 .trailing_button(|| {
806 split_trailing_button()
807 .filled()
808 .size(SplitButtonSize::ExtraLarge)
809 .modifier(Modifier::new().semantics(SemanticsArgs {
810 test_tag: Some("split-trailing".to_string()),
811 ..Default::default()
812 }))
813 .content(|| {
814 text("More");
815 });
816 });
817 });
818 }
819
820 #[test]
821 fn layout_preserves_the_trailing_segment_under_mixed_sizes() {
822 let runtime = tessera_ui::RuntimeSession::new();
823 let _scope = runtime.enter();
824 tessera_ui::dp::set_scale_factor(1.0);
825 tessera_ui::assert_layout! {
826 viewport: (400, 200),
827 content: {
828 split_button_case();
829 },
830 expect: {
831 node("split-leading").height(136);
832 node("split-trailing").height(136);
833 }
834 }
835 }
836}