Skip to main content

tessera_components/
split_buttons.rs

1//! Material Design split buttons for primary and secondary actions.
2//!
3//! ## Usage
4//!
5//! Pair a primary action with a related secondary action or menu.
6
7use 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/// Sizes supported by split buttons.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum SplitButtonSize {
30    /// Extra small split buttons (32dp height).
31    ExtraSmall,
32    /// Small split buttons (40dp height).
33    #[default]
34    Small,
35    /// Medium split buttons (56dp height).
36    Medium,
37    /// Large split buttons (96dp height).
38    Large,
39    /// Extra large split buttons (136dp height).
40    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/// Visual emphasis variants for split buttons.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub enum SplitButtonVariant {
57    /// High-emphasis filled variant.
58    #[default]
59    Filled,
60    /// Medium-emphasis tonal variant.
61    Tonal,
62    /// Elevated variant with shadow.
63    Elevated,
64    /// Medium-emphasis outlined variant.
65    Outlined,
66}
67
68/// Color values for split buttons in different states.
69#[derive(Clone, PartialEq, Copy, Debug)]
70pub struct SplitButtonColors {
71    /// Container color when enabled.
72    pub container_color: Color,
73    /// Content color when enabled.
74    pub content_color: Color,
75    /// Border color when enabled.
76    pub border_color: Color,
77    /// Container color when disabled.
78    pub disabled_container_color: Color,
79    /// Content color when disabled.
80    pub disabled_content_color: Color,
81    /// Border color when disabled.
82    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
111/// Defaults for split buttons.
112pub struct SplitButtonDefaults;
113
114impl SplitButtonDefaults {
115    /// Minimum width of split button items.
116    pub const MIN_WIDTH: Dp = Dp(48.0);
117    /// Default spacing between leading and trailing buttons.
118    pub const SPACING: Dp = Dp(2.0);
119    /// Default leading icon size.
120    pub const LEADING_ICON_SIZE: Dp = Dp(20.0);
121
122    /// Returns the leading icon size for the selected container height.
123    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    /// Returns the recommended space between a leading icon and its label.
133    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    /// Returns the container height for the provided size.
142    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    /// Returns the inner corner size for the provided size.
153    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    /// Returns the inner corner radius for hover, focus, and press feedback.
164    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    /// Returns the menu icon's resting optical offset from the geometric
173    /// center.
174    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    /// Returns the default leading button padding for the provided size.
184    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    /// Returns the default trailing button padding for the provided size.
196    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    /// Returns the trailing icon size for the provided size.
208    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    /// Returns the minimum trailing width including both content insets.
219    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    /// Returns the default leading button shape for the provided size.
229    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    /// Returns the default trailing button shape for the provided size.
240    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    /// Returns default colors for the requested variant.
251    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    /// Returns the default border width for a split button variant.
307    pub fn border_width(variant: SplitButtonVariant) -> Dp {
308        match variant {
309            SplitButtonVariant::Outlined => Dp(1.0),
310            _ => Dp(0.0),
311        }
312    }
313
314    /// Returns the outline thickness for a variant and size.
315    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    /// Returns the default elevation for a split button variant.
327    pub fn elevation(variant: SplitButtonVariant) -> Option<Dp> {
328        match variant {
329            SplitButtonVariant::Elevated => Some(Dp(1.0)),
330            _ => None,
331        }
332    }
333
334    /// Returns the tonal elevation for split button variants.
335    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/// # split_button_layout
379///
380/// Lay out a split button with leading and trailing actions.
381///
382/// ## Usage
383///
384/// Group a primary action with a related secondary action or menu.
385///
386/// ## Parameters
387///
388/// - `modifier` — optional modifier chain applied to the split button layout.
389/// - `spacing` — optional spacing between the leading and trailing buttons.
390/// - `leading_button` — optional leading button slot.
391/// - `trailing_button` — optional trailing button slot.
392///
393/// ## Examples
394///
395/// ```
396/// use tessera_components::{
397///     icon::icon,
398///     material_icons::filled,
399///     menus::MenuController,
400///     split_buttons::{split_button_layout, split_leading_button, split_trailing_button},
401///     text::text,
402///     theme::material_theme,
403/// };
404/// use tessera_ui::{remember, tessera};
405///
406/// #[tessera]
407/// fn component() {
408///     let menu = remember(MenuController::new);
409///     material_theme().child(move || {
410///         split_button_layout()
411///             .leading_button(|| {
412///                 split_leading_button().on_click(|| {}).content(|| {
413///                     text("Send");
414///                 });
415///             })
416///             .trailing_button(move || {
417///                 split_trailing_button()
418///                     .expanded(menu.with(MenuController::is_open))
419///                     .accessibility_label("Send options")
420///                     .on_click(move || menu.with_mut(MenuController::toggle))
421///                     .content(|| {
422///                         icon().painter(filled::ARROW_DROP_DOWN_SVG);
423///                     });
424///             });
425///     });
426/// }
427/// # tessera_ui::testing::layout_test(component).run();
428/// ```
429#[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/// # split_leading_button
450///
451/// Render the leading button for a split button pair.
452///
453/// ## Usage
454///
455/// Use as the primary action within a split button.
456///
457/// ## Parameters
458///
459/// - `variant` — visual emphasis variant.
460/// - `size` — split button size preset.
461/// - `enabled` — optional enabled state; defaults to `true`.
462/// - `modifier` — optional modifier chain applied to the leading button.
463/// - `on_click` — optional click callback.
464/// - `accessibility_label` — optional accessibility label.
465/// - `accessibility_description` — optional accessibility description.
466/// - `interaction_state` — optional shared pointer and focus state.
467/// - `shape` — fixed shape override that suppresses automatic corner changes.
468/// - `content` — leading icon and label, sized and placed in a row.
469///
470/// ## Examples
471///
472/// ```
473/// use tessera_components::{
474///     split_buttons::split_leading_button, text::text, theme::material_theme,
475/// };
476/// use tessera_ui::{remember, tessera};
477///
478/// #[tessera]
479/// fn component() {
480///     let activations = remember(|| 0_u32);
481///     material_theme().child(move || {
482///         split_leading_button()
483///             .accessibility_label("Send")
484///             .on_click(move || activations.set(activations.get() + 1))
485///             .content(|| {
486///                 text("Send");
487///             });
488///     });
489///     assert_eq!(activations.get(), 0);
490/// }
491/// # tessera_ui::testing::layout_test(component).run();
492/// ```
493#[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/// # split_trailing_button
531///
532/// Render the trailing button for a split button pair.
533///
534/// ## Usage
535///
536/// Use as a secondary action or menu affordance within a split button.
537///
538/// ## Parameters
539///
540/// - `variant` — visual emphasis variant.
541/// - `size` — split button size preset.
542/// - `enabled` — optional enabled state; defaults to `true`.
543/// - `modifier` — optional modifier chain applied to the trailing button.
544/// - `on_click` — optional click callback.
545/// - `accessibility_label` — optional accessibility label.
546/// - `accessibility_description` — optional accessibility description.
547/// - `interaction_state` — optional shared pointer and focus state.
548/// - `shape` — fixed shape override that suppresses automatic corner changes.
549/// - `expanded` — controlled menu disclosure state with popup semantics.
550/// - `content` — menu icon, sized automatically for the preset.
551///
552/// ## Examples
553///
554/// ```
555/// use tessera_components::{
556///     icon::icon, material_icons::filled, menus::MenuController,
557///     split_buttons::split_trailing_button, theme::material_theme,
558/// };
559/// use tessera_ui::{remember, tessera};
560///
561/// #[tessera]
562/// fn component() {
563///     let menu = remember(MenuController::new);
564///     material_theme().child(move || {
565///         split_trailing_button()
566///             .expanded(menu.with(MenuController::is_open))
567///             .accessibility_label("Send options")
568///             .on_click(move || menu.with_mut(MenuController::toggle))
569///             .content(|| {
570///                 icon().painter(filled::ARROW_DROP_DOWN_SVG);
571///             });
572///     });
573/// }
574/// # tessera_ui::testing::layout_test(component).run();
575/// ```
576#[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}