Skip to main content

tessera_components/
button.rs

1//! Material-styled button components.
2//!
3//! ## Usage
4//!
5//! Trigger actions, submit forms, or navigate.
6use tessera_ui::{
7    AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FrameNanosControl, LayoutResult,
8    MeasurementError, Modifier, Px, PxPosition, RenderSlot, State,
9    accesskit::{Role, Toggled},
10    context::provide_context,
11    current_frame_nanos,
12    layout::{MeasureScope, layout},
13    receive_frame_nanos, remember, tessera, use_context,
14};
15
16use crate::{
17    alignment::Alignment,
18    animation::Animatable,
19    boxed::boxed,
20    icon::IconSize,
21    modifier::{InteractionState, InteractionVisualState, ModifierExt, Padding},
22    shape_def::{RoundedCorner, Shape},
23    surface::{RipplePresentation, SurfaceStyle, surface},
24    theme::{MaterialAlpha, MaterialColorScheme, MaterialTheme, TextStyle, content_color_for},
25};
26
27/// Visual variants of the Material Design 3 [`button`].
28///
29/// Each variant resolves its container, content, and border colors from the
30/// active [`MaterialColorScheme`] unless explicitly overridden.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum ButtonVariant {
33    /// Filled button using the primary container color. High emphasis.
34    #[default]
35    Filled,
36    /// Filled button on a low surface container with a shadow.
37    Elevated,
38    /// Filled button using the secondary container color. Medium emphasis.
39    Tonal,
40    /// Transparent button with an outline border. Medium emphasis.
41    Outlined,
42    /// Transparent button without a border. Low emphasis.
43    Text,
44}
45/// Resting shape of an Expressive button.
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub enum ButtonShape {
48    /// Fully rounded ends, changing to square corners when selected.
49    #[default]
50    Round,
51    /// Size-specific corners, changing to rounded ends when selected.
52    Square,
53}
54
55/// Expressive button dimensions and content metrics.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum ButtonSize {
58    /// A 32dp container with 20dp icons.
59    ExtraSmall,
60    /// A 40dp container with 20dp icons.
61    #[default]
62    Small,
63    /// A 56dp container with 24dp icons.
64    Medium,
65    /// A 96dp container with 32dp icons.
66    Large,
67    /// A 136dp container with 40dp icons.
68    ExtraLarge,
69}
70
71impl ButtonSize {
72    /// Returns the minimum visual container height.
73    pub const fn height(self) -> Dp {
74        Dp(match self {
75            Self::ExtraSmall => 32.0,
76            Self::Small => 40.0,
77            Self::Medium => 56.0,
78            Self::Large => 96.0,
79            Self::ExtraLarge => 136.0,
80        })
81    }
82
83    /// Returns the recommended leading and trailing icon size.
84    pub const fn icon_size(self) -> Dp {
85        Dp(match self {
86            Self::ExtraSmall | Self::Small => 20.0,
87            Self::Medium => 24.0,
88            Self::Large => 32.0,
89            Self::ExtraLarge => 40.0,
90        })
91    }
92
93    /// Returns the spacing between icons and the label.
94    pub const fn icon_spacing(self) -> Dp {
95        Dp(match self {
96            Self::ExtraSmall => 4.0,
97            Self::Small | Self::Medium => 8.0,
98            Self::Large => 12.0,
99            Self::ExtraLarge => 16.0,
100        })
101    }
102
103    /// Returns the default content insets.
104    pub const fn content_padding(self) -> Padding {
105        let (horizontal, vertical) = match self {
106            Self::ExtraSmall => (12.0, 6.0),
107            Self::Small => (16.0, 10.0),
108            Self::Medium => (24.0, 16.0),
109            Self::Large => (48.0, 32.0),
110            Self::ExtraLarge => (64.0, 48.0),
111        };
112        Padding::symmetric(Dp(horizontal), Dp(vertical))
113    }
114
115    /// Returns the default outline thickness.
116    pub const fn outline_width(self) -> Dp {
117        Dp(match self {
118            Self::ExtraSmall | Self::Small | Self::Medium => 1.0,
119            Self::Large => 2.0,
120            Self::ExtraLarge => 3.0,
121        })
122    }
123
124    /// Returns the resting square corner radius.
125    pub const fn square_radius(self) -> Dp {
126        Dp(match self {
127            Self::ExtraSmall | Self::Small => 12.0,
128            Self::Medium => 16.0,
129            Self::Large | Self::ExtraLarge => 28.0,
130        })
131    }
132
133    /// Returns the pressed corner radius for both shape presets.
134    pub const fn pressed_radius(self) -> Dp {
135        Dp(match self {
136            Self::ExtraSmall | Self::Small => 8.0,
137            Self::Medium => 12.0,
138            Self::Large | Self::ExtraLarge => 16.0,
139        })
140    }
141
142    fn text_style(self, theme: &MaterialTheme) -> TextStyle {
143        match self {
144            Self::ExtraSmall | Self::Small => theme.typography.label_large,
145            Self::Medium => theme.typography.title_medium,
146            Self::Large => theme.typography.headline_small,
147            Self::ExtraLarge => theme.typography.headline_large,
148        }
149    }
150}
151
152/// Material Design 3 defaults for [`button`].
153pub struct ButtonDefaults;
154
155impl ButtonDefaults {
156    /// Default pressed alpha used for ripple feedback.
157    pub const PRESSED_ALPHA: f32 = MaterialAlpha::PRESSED;
158    /// Default disabled container alpha.
159    pub const DISABLED_CONTAINER_ALPHA: f32 = MaterialAlpha::DISABLED_CONTAINER;
160    /// Default disabled content alpha.
161    pub const DISABLED_CONTENT_ALPHA: f32 = MaterialAlpha::DISABLED_CONTENT;
162    /// Disabled container opacity used by filled/elevated buttons.
163    pub const FILLED_DISABLED_CONTAINER_ALPHA: f32 = 0.1;
164    /// Disabled content opacity used by most buttons.
165    pub const DISABLED_LABEL_ALPHA: f32 = 0.38;
166    /// Minimum width for buttons (Material default = 58dp).
167    pub const MIN_WIDTH: Dp = Dp(58.0);
168    /// Minimum height for buttons (Material default = 40dp).
169    pub const MIN_HEIGHT: Dp = Dp(40.0);
170    /// Horizontal padding used inside buttons.
171    pub const CONTENT_HORIZONTAL_PADDING: Dp = Dp(16.0);
172    /// Vertical padding used inside buttons.
173    pub const CONTENT_VERTICAL_PADDING: Dp = Dp(10.0);
174
175    /// Default disabled container color for filled buttons.
176    pub fn disabled_container_color(scheme: &MaterialColorScheme) -> Color {
177        scheme
178            .on_surface
179            .with_alpha(Self::FILLED_DISABLED_CONTAINER_ALPHA)
180    }
181
182    /// Default disabled content color for filled buttons.
183    pub fn disabled_content_color(scheme: &MaterialColorScheme) -> Color {
184        scheme
185            .on_surface_variant
186            .with_alpha(Self::DISABLED_LABEL_ALPHA)
187    }
188
189    /// Default disabled border color for outlined buttons.
190    pub fn disabled_border_color(scheme: &MaterialColorScheme) -> Color {
191        scheme
192            .outline_variant
193            .with_alpha(Self::FILLED_DISABLED_CONTAINER_ALPHA)
194    }
195}
196
197#[derive(Clone, Copy, Debug, PartialEq)]
198struct ButtonMotionTarget {
199    fraction: f32,
200    radius: f32,
201    elevation: f32,
202    outline: f32,
203    toggle: bool,
204}
205
206impl ButtonMotionTarget {
207    fn corner(size: ButtonSize, shape: ButtonShape, selected: bool, pressed: bool) -> (f32, f32) {
208        if pressed {
209            (0.0, size.pressed_radius().0 as f32)
210        } else if (shape == ButtonShape::Round) != selected {
211            (0.5, 0.0)
212        } else {
213            (0.0, size.square_radius().0 as f32)
214        }
215    }
216}
217
218struct ButtonMotion {
219    fraction: Animatable,
220    radius: Animatable,
221    elevation: Animatable,
222    outline: Animatable,
223    target: ButtonMotionTarget,
224    frame_nanos: u64,
225}
226
227impl ButtonMotion {
228    fn spatial(value: f32, toggle: bool) -> Animatable {
229        if toggle {
230            Animatable::spring(value, 800.0_f32.sqrt(), 0.6)
231        } else {
232            Animatable::spring(value, 40.0, 1.0)
233        }
234    }
235
236    fn new(target: ButtonMotionTarget, frame_nanos: u64) -> Self {
237        Self {
238            fraction: Self::spatial(target.fraction, target.toggle),
239            radius: Self::spatial(target.radius, target.toggle),
240            elevation: Animatable::spring(target.elevation, 40.0, 1.0),
241            outline: Self::spatial(target.outline, true),
242            target,
243            frame_nanos,
244        }
245    }
246
247    fn retarget(&mut self, target: ButtonMotionTarget, frame_nanos: u64) {
248        self.advance(frame_nanos);
249        if target.toggle != self.target.toggle {
250            self.fraction = Self::spatial(self.fraction.value(), target.toggle)
251                .with_velocity(self.fraction.velocity());
252            self.radius = Self::spatial(self.radius.value(), target.toggle)
253                .with_velocity(self.radius.velocity());
254        }
255        self.fraction.set_target(target.fraction);
256        self.radius.set_target(target.radius);
257        self.elevation.set_target(target.elevation);
258        if target.toggle {
259            self.outline.set_target(target.outline);
260        } else {
261            self.outline = Self::spatial(target.outline, true);
262        }
263        self.target = target;
264    }
265
266    fn advance(&mut self, frame_nanos: u64) {
267        if frame_nanos > self.frame_nanos {
268            let dt = (frame_nanos - self.frame_nanos) as f32 / 1_000_000_000.0;
269            self.fraction.tick(dt);
270            self.radius.tick(dt);
271            self.elevation.tick(dt);
272            self.outline.tick(dt);
273            self.frame_nanos = frame_nanos;
274        }
275    }
276
277    fn is_idle(&self) -> bool {
278        self.fraction.is_idle()
279            && self.radius.is_idle()
280            && self.elevation.is_idle()
281            && self.outline.is_idle()
282    }
283
284    fn shape(&self) -> Shape {
285        let corner =
286            RoundedCorner::relative(self.fraction.value(), Dp(self.radius.value() as f64), 2.0);
287        Shape::RoundedRectangle {
288            top_left: corner,
289            top_right: corner,
290            bottom_right: corner,
291            bottom_left: corner,
292        }
293    }
294}
295
296#[derive(Clone)]
297struct ButtonResolvedArgs {
298    enabled: bool,
299    modifier: Modifier,
300    color: Color,
301    content_color: Color,
302    shape: Shape,
303    padding: Padding,
304    on_click: Option<Callback>,
305    ripple_color: Color,
306    border_width: Dp,
307    border_color: Option<Color>,
308    elevation: Option<Dp>,
309    tonal_elevation: Dp,
310    disabled_container_color: Color,
311    disabled_content_color: Color,
312    disabled_border_color: Color,
313    accessibility_label: Option<String>,
314    accessibility_description: Option<String>,
315    child: Option<RenderSlot>,
316}
317
318/// # button
319///
320/// Provides a clickable button with customizable style and ripple feedback.
321///
322/// ## Usage
323///
324/// Use to trigger an action when the user clicks or taps.
325///
326/// ## Parameters
327///
328/// - `variant` — optional visual variant
329///   (`filled`/`elevated`/`tonal`/`outlined`/`text`).
330/// - `size` — Expressive size preset; defaults to small.
331/// - `shape_style` — round or square preset; defaults to round.
332/// - `selected` — controlled toggle state; omit for an ordinary action. Text
333///   buttons do not support toggling. Update this value in `on_click`.
334/// - `interaction_state` — optional shared state tracking live pointer and
335///   focus interactions.
336/// - `presentation_ripple` — optional static circular ripple for pressed visual
337///   presentation.
338/// - `presentation_state` — optional visual interaction without changing input
339///   or focus.
340/// - `enabled` — optional enabled flag.
341/// - `modifier` — modifier chain applied to the button subtree.
342/// - `color` — optional container color override.
343/// - `content_color` — optional content color override.
344/// - `shape` — fixed shape override, replacing automatic corner morphing.
345/// - `padding` — optional uniform internal padding override.
346/// - `content_padding` — optional per-edge insets; takes precedence over
347///   `padding`.
348/// - `leading_icon`, `trailing_icon` — optional icons, sized and spaced by the
349///   preset.
350/// - `on_click` — optional click callback.
351/// - `ripple_color` — optional ripple tint override.
352/// - `border_width` — optional outline width.
353/// - `border_color` — optional outline color.
354/// - `elevation` — optional surface elevation.
355/// - `tonal_elevation` — optional tonal elevation.
356/// - `disabled_container_color` — optional disabled container color.
357/// - `disabled_content_color` — optional disabled content color.
358/// - `disabled_border_color` — optional disabled border color.
359/// - `accessibility_label` — optional accessibility label.
360/// - `accessibility_description` — optional accessibility description.
361/// - `accessibility_role` — optional semantic role; defaults to button.
362/// - `child` — optional child render slot.
363///
364/// ## Examples
365///
366/// ```
367/// # use tessera_ui::tessera;
368/// # #[tessera]
369/// # fn component() {
370/// use tessera_components::{button::button, text::text};
371/// # use tessera_components::theme::{MaterialTheme, material_theme};
372///
373/// # material_theme()
374/// #     .theme(|| MaterialTheme::default())
375/// #     .child(|| {
376/// let selected = tessera_ui::remember(|| false);
377/// let toggle = tessera_ui::Callback::new(move || selected.set(!selected.get()));
378/// button()
379///     .tonal()
380///     .square()
381///     .selected(selected.get())
382///     .on_click_shared(toggle)
383///     .child(|| {
384///         text("Favorite");
385///     });
386/// assert!(!selected.get());
387/// toggle.call();
388/// assert!(selected.get());
389/// #     });
390/// # }
391/// # tessera_ui::testing::layout_test(component).run();
392/// ```
393/// Renders a Material button.
394#[tessera]
395pub fn button(
396    variant: Option<ButtonVariant>,
397    size: Option<ButtonSize>,
398    shape_style: Option<ButtonShape>,
399    selected: Option<bool>,
400    interaction_state: Option<State<InteractionState>>,
401    presentation_state: Option<InteractionVisualState>,
402    presentation_ripple: Option<RipplePresentation>,
403    enabled: Option<bool>,
404    modifier: Option<Modifier>,
405    color: Option<Color>,
406    content_color: Option<Color>,
407    shape: Option<Shape>,
408    padding: Option<Dp>,
409    content_padding: Option<Padding>,
410    leading_icon: Option<RenderSlot>,
411    trailing_icon: Option<RenderSlot>,
412    on_click: Option<Callback>,
413    ripple_color: Option<Color>,
414    border_width: Option<Dp>,
415    border_color: Option<Color>,
416    elevation: Option<Dp>,
417    tonal_elevation: Option<Dp>,
418    disabled_container_color: Option<Color>,
419    disabled_content_color: Option<Color>,
420    disabled_border_color: Option<Color>,
421    #[prop(into)] accessibility_label: Option<String>,
422    #[prop(into)] accessibility_description: Option<String>,
423    accessibility_role: Option<Role>,
424    child: Option<RenderSlot>,
425) {
426    let scheme = use_context::<MaterialTheme>()
427        .expect("MaterialTheme must be provided")
428        .get()
429        .color_scheme;
430    let variant = variant.unwrap_or_default();
431    let size = size.unwrap_or_default();
432    assert!(
433        variant != ButtonVariant::Text || selected.is_none(),
434        "text buttons do not support toggle selection"
435    );
436    let enabled = enabled.unwrap_or(true);
437    let interaction_state = if let Some(state) = interaction_state {
438        state
439    } else {
440        remember(InteractionState::new)
441    };
442    if !enabled
443        && interaction_state.with(|state| {
444            state.is_pressed() || state.is_hovered() || state.is_focused() || state.is_dragged()
445        })
446    {
447        interaction_state.with_mut(|state| *state = InteractionState::new());
448    }
449    let visual =
450        interaction_state.with(|state| state.with_presentation(presentation_state, enabled));
451    let pressed = visual.is_pressed();
452    let hovered = visual.is_hovered();
453    let focused = visual.is_focused();
454    let (
455        default_container_color,
456        default_content_color,
457        default_ripple_color,
458        default_border_width,
459        default_border_color,
460        default_elevation,
461        default_disabled_container_color,
462        default_disabled_border_color,
463    ) = match variant {
464        ButtonVariant::Filled => (
465            scheme.primary,
466            scheme.on_primary,
467            scheme.on_primary,
468            Dp(0.0),
469            None,
470            None,
471            ButtonDefaults::disabled_container_color(&scheme),
472            ButtonDefaults::disabled_border_color(&scheme),
473        ),
474        ButtonVariant::Elevated => (
475            scheme.surface_container_low,
476            scheme.primary,
477            scheme.primary,
478            Dp(0.0),
479            None,
480            Some(Dp(1.0)),
481            ButtonDefaults::disabled_container_color(&scheme),
482            ButtonDefaults::disabled_border_color(&scheme),
483        ),
484        ButtonVariant::Tonal => (
485            scheme.secondary_container,
486            scheme.on_secondary_container,
487            scheme.on_secondary_container,
488            Dp(0.0),
489            None,
490            None,
491            ButtonDefaults::disabled_container_color(&scheme),
492            ButtonDefaults::disabled_border_color(&scheme),
493        ),
494        ButtonVariant::Outlined => (
495            Color::TRANSPARENT,
496            scheme.on_surface_variant,
497            scheme.on_surface_variant,
498            size.outline_width(),
499            Some(scheme.outline_variant),
500            None,
501            Color::TRANSPARENT,
502            ButtonDefaults::disabled_border_color(&scheme),
503        ),
504        ButtonVariant::Text => (
505            Color::TRANSPARENT,
506            scheme.primary,
507            scheme.primary,
508            Dp(0.0),
509            None,
510            None,
511            Color::TRANSPARENT,
512            ButtonDefaults::disabled_border_color(&scheme),
513        ),
514    };
515    let (default_container_color, default_content_color) = match (variant, selected) {
516        (ButtonVariant::Filled, Some(false)) => {
517            (scheme.surface_container, scheme.on_surface_variant)
518        }
519        (ButtonVariant::Filled | ButtonVariant::Elevated, Some(true)) => {
520            (scheme.primary, scheme.on_primary)
521        }
522        (ButtonVariant::Tonal, Some(true)) => (scheme.secondary, scheme.on_secondary),
523        (ButtonVariant::Outlined, Some(true)) => {
524            (scheme.inverse_surface, scheme.inverse_on_surface)
525        }
526        _ => (default_container_color, default_content_color),
527    };
528    let default_border_width = if variant == ButtonVariant::Outlined && selected == Some(true) {
529        Dp(0.0)
530    } else {
531        default_border_width
532    };
533    let default_disabled_container_color =
534        if variant == ButtonVariant::Outlined && selected == Some(true) {
535            ButtonDefaults::disabled_container_color(&scheme)
536        } else {
537            default_disabled_container_color
538        };
539    let default_elevation = if !enabled {
540        Dp(0.0)
541    } else if variant == ButtonVariant::Elevated {
542        Dp(if hovered && !pressed && !focused {
543            3.0
544        } else {
545            1.0
546        })
547    } else if matches!(variant, ButtonVariant::Filled | ButtonVariant::Tonal)
548        && hovered
549        && !pressed
550        && !focused
551    {
552        Dp(1.0)
553    } else {
554        default_elevation.unwrap_or(Dp(0.0))
555    };
556    let (fraction, radius) = if shape.is_some() {
557        (0.0, 0.0)
558    } else {
559        ButtonMotionTarget::corner(
560            size,
561            shape_style.unwrap_or_default(),
562            selected.unwrap_or(false),
563            pressed,
564        )
565    };
566    let target = ButtonMotionTarget {
567        fraction,
568        radius,
569        elevation: elevation.unwrap_or(default_elevation).0 as f32,
570        outline: border_width.unwrap_or(default_border_width).0 as f32,
571        toggle: selected.is_some(),
572    };
573    let frame = current_frame_nanos();
574    let motion = remember(|| ButtonMotion::new(target, frame));
575    if motion.with(|motion| motion.target) != target {
576        motion.with_mut(|motion| motion.retarget(target, frame));
577    }
578    if !motion.with(ButtonMotion::is_idle) {
579        receive_frame_nanos(move |frame| {
580            motion.with_mut(|motion| motion.advance(frame));
581            if motion.with(ButtonMotion::is_idle) {
582                FrameNanosControl::Stop
583            } else {
584                FrameNanosControl::Continue
585            }
586        });
587    }
588    let container_color = color.unwrap_or(default_container_color);
589    let content_color = content_color
590        .or_else(|| color.and_then(|color| content_color_for(color, &scheme)))
591        .unwrap_or(default_content_color);
592    let button_args = ButtonResolvedArgs {
593        enabled,
594        modifier: modifier.unwrap_or_default(),
595        color: container_color,
596        content_color,
597        shape: shape.unwrap_or_else(|| motion.with(ButtonMotion::shape)),
598        padding: content_padding
599            .or_else(|| padding.map(Padding::all))
600            .unwrap_or_else(|| size.content_padding()),
601        on_click,
602        ripple_color: ripple_color.unwrap_or(if selected.is_some() {
603            content_color
604        } else {
605            default_ripple_color
606        }),
607        border_width: Dp(motion.with(|motion| motion.outline.value()).max(0.0) as f64),
608        border_color: border_color.or(default_border_color),
609        elevation: Some(Dp(
610            motion.with(|motion| motion.elevation.value()).max(0.0) as f64
611        )),
612        tonal_elevation: tonal_elevation.unwrap_or(Dp(0.0)),
613        disabled_container_color: disabled_container_color
614            .unwrap_or(default_disabled_container_color),
615        disabled_content_color: disabled_content_color
616            .unwrap_or_else(|| ButtonDefaults::disabled_content_color(&scheme)),
617        disabled_border_color: disabled_border_color.unwrap_or(default_disabled_border_color),
618        accessibility_label,
619        accessibility_description,
620        child,
621    };
622    let child = button_args.child;
623    let padding = button_args.padding;
624    let text_style = size.text_style(
625        &use_context::<MaterialTheme>()
626            .expect("MaterialTheme must be provided")
627            .get(),
628    );
629    let child = RenderSlot::new(move || {
630        layout()
631            .modifier(Modifier::new().padding(padding))
632            .child(move || {
633                provide_context(
634                    || text_style,
635                    move || {
636                        let inherited = use_context::<TextStyle>().expect("button text style");
637                        if inherited.get() != text_style {
638                            inherited.set(text_style);
639                        }
640                        if leading_icon.is_some() || trailing_icon.is_some() {
641                            layout()
642                                .policy(ButtonContentLayout {
643                                    leading: leading_icon.is_some(),
644                                    label: child.is_some(),
645                                    trailing: trailing_icon.is_some(),
646                                    icon_size: size.icon_size().to_px(),
647                                    spacing: size.icon_spacing().to_px(),
648                                })
649                                .child(move || {
650                                    if let Some(icon) = leading_icon {
651                                        boxed()
652                                            .modifier(
653                                                Modifier::new()
654                                                    .size(size.icon_size(), size.icon_size()),
655                                            )
656                                            .children(move || {
657                                                provide_context(
658                                                    || IconSize(size.icon_size()),
659                                                    move || {
660                                                        let inherited = use_context::<IconSize>()
661                                                            .expect("button icon size");
662                                                        if inherited.get().0 != size.icon_size() {
663                                                            inherited
664                                                                .set(IconSize(size.icon_size()));
665                                                        }
666                                                        icon.render();
667                                                    },
668                                                );
669                                            });
670                                    }
671                                    if let Some(child) = child {
672                                        boxed().children_shared(child);
673                                    }
674                                    if let Some(icon) = trailing_icon {
675                                        boxed()
676                                            .modifier(
677                                                Modifier::new()
678                                                    .size(size.icon_size(), size.icon_size()),
679                                            )
680                                            .children(move || {
681                                                provide_context(
682                                                    || IconSize(size.icon_size()),
683                                                    move || {
684                                                        let inherited = use_context::<IconSize>()
685                                                            .expect("button icon size");
686                                                        if inherited.get().0 != size.icon_size() {
687                                                            inherited
688                                                                .set(IconSize(size.icon_size()));
689                                                        }
690                                                        icon.render();
691                                                    },
692                                                );
693                                            });
694                                    }
695                                });
696                        } else if let Some(child) = child {
697                            child.render();
698                        }
699                    },
700                );
701            });
702    });
703    let container_color = if button_args.enabled {
704        button_args.color
705    } else {
706        button_args.disabled_container_color
707    };
708
709    let content_color = if button_args.enabled {
710        button_args.content_color
711    } else {
712        button_args.disabled_content_color
713    };
714
715    let style = if button_args.border_width.to_pixels_f32() > 0.0 {
716        let border_color = if button_args.enabled {
717            button_args.border_color.unwrap_or(container_color)
718        } else {
719            button_args.disabled_border_color
720        };
721        SurfaceStyle::FilledOutlined {
722            fill_color: container_color,
723            border_color,
724            border_width: button_args.border_width,
725        }
726    } else {
727        SurfaceStyle::Filled {
728            color: container_color,
729        }
730    };
731    let on_click = button_args.on_click;
732
733    surface()
734        .style(style)
735        .shape(button_args.shape)
736        .modifier(
737            button_args
738                .modifier
739                .clone()
740                .default_min_size(ButtonDefaults::MIN_WIDTH, size.height()),
741        )
742        .ripple_color(button_args.ripple_color)
743        .content_alignment(Alignment::Center)
744        .content_color(content_color)
745        .enabled(button_args.enabled)
746        .tonal_elevation(button_args.tonal_elevation)
747        .interaction_state(interaction_state)
748        .presentation_state_optional(presentation_state)
749        .presentation_ripple_optional(presentation_ripple)
750        .show_focus_indicator(true)
751        .accessibility_toggled_optional(selected.map(|selected| {
752            if selected {
753                Toggled::True
754            } else {
755                Toggled::False
756            }
757        }))
758        .accessibility_role(accessibility_role.unwrap_or(Role::Button))
759        .accessibility_focusable(button_args.enabled)
760        .elevation_optional(button_args.elevation)
761        .on_click_optional(on_click)
762        .accessibility_label_optional(button_args.accessibility_label)
763        .accessibility_description_optional(button_args.accessibility_description)
764        .child_shared(child);
765}
766
767#[derive(Clone, Copy, PartialEq)]
768struct ButtonContentLayout {
769    leading: bool,
770    label: bool,
771    trailing: bool,
772    icon_size: Px,
773    spacing: Px,
774}
775
776impl tessera_ui::LayoutModifierNode for ButtonContentLayout {
777    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
778        let children = input.children();
779        let expected =
780            usize::from(self.leading) + usize::from(self.label) + usize::from(self.trailing);
781        if children.len() != expected {
782            return Err(MeasurementError::MeasureFnFailed(
783                "Button slots require one grouped child each".to_string(),
784            ));
785        }
786        let parent = *input.parent_constraint().as_ref();
787        let icons = i32::from(self.leading) + i32::from(self.trailing);
788        let gaps = expected.saturating_sub(1) as i32;
789        let icon_budget = parent
790            .width
791            .resolve_max()
792            .map_or(self.icon_size * icons, |max| {
793                max.min(self.icon_size * icons)
794            });
795        let icon_width = icon_budget / icons;
796        let spacing = if gaps == 0 {
797            Px::ZERO
798        } else {
799            parent.width.resolve_max().map_or(self.spacing, |max| {
800                self.spacing.min((max - icon_budget).max(Px::ZERO) / gaps)
801            })
802        };
803        let mut sizes = vec![
804            ComputedData {
805                width: Px::ZERO,
806                height: Px::ZERO
807            };
808            expected
809        ];
810        let mut icon_total = Px::ZERO;
811        for index in 0..expected {
812            if (self.leading && index == 0) || (self.trailing && index == expected - 1) {
813                sizes[index] = children[index]
814                    .measure(
815                        input,
816                        &Constraint::new(
817                            AxisConstraint::new(Px::ZERO, Some(icon_width)),
818                            parent.height.without_min(),
819                        ),
820                    )?
821                    .size();
822                icon_total += sizes[index].width;
823            }
824        }
825        if self.label {
826            let index = usize::from(self.leading);
827            sizes[index] = children[index]
828                .measure(
829                    input,
830                    &Constraint::new(
831                        AxisConstraint::new(
832                            Px::ZERO,
833                            parent
834                                .width
835                                .resolve_max()
836                                .map(|max| (max - icon_total - spacing * gaps).max(Px::ZERO)),
837                        ),
838                        parent.height.without_min(),
839                    ),
840                )?
841                .size();
842        }
843        let content_width = sizes
844            .iter()
845            .fold(spacing * gaps, |width, size| width + size.width);
846        let content_height = sizes
847            .iter()
848            .fold(Px::ZERO, |height, size| height.max(size.height));
849        let width = parent.width.clamp(content_width);
850        let height = parent.height.clamp(content_height);
851        let mut result = LayoutResult::new(ComputedData { width, height });
852        let mut x = Px::ZERO;
853        for (child, size) in children.into_iter().zip(sizes) {
854            result.place_child(
855                child,
856                PxPosition::new(x, (height - size.height).max(Px::ZERO) / 2),
857            );
858            x += size.width + spacing;
859        }
860        Ok(result)
861    }
862}
863
864impl ButtonBuilder {
865    /// Uses fully rounded ends in the unselected resting state.
866    pub fn round(&self) -> Self {
867        self.shape_style(ButtonShape::Round)
868    }
869
870    /// Uses the size-specific square corners in the unselected resting state.
871    pub fn square(&self) -> Self {
872        self.shape_style(ButtonShape::Square)
873    }
874
875    /// Applies the standard "Filled" button preset (High emphasis).
876    ///
877    /// Uses the theme Primary container with OnPrimary content.
878    pub fn filled(&self) -> Self {
879        self.variant(ButtonVariant::Filled)
880    }
881
882    /// Applies the "Elevated" button preset (Medium emphasis).
883    ///
884    /// Uses the theme SurfaceContainerLow container with a shadow.
885    pub fn elevated(&self) -> Self {
886        self.variant(ButtonVariant::Elevated)
887    }
888
889    /// Applies the "Tonal" button preset (Medium emphasis).
890    ///
891    /// Uses the theme SecondaryContainer container with OnSecondaryContainer
892    /// content.
893    pub fn tonal(&self) -> Self {
894        self.variant(ButtonVariant::Tonal)
895    }
896
897    /// Applies the "Outlined" button preset (Medium emphasis).
898    ///
899    /// Uses a transparent container with an Outline border.
900    pub fn outlined(&self) -> Self {
901        self.variant(ButtonVariant::Outlined)
902    }
903
904    /// Applies the "Text" button preset (Low emphasis).
905    ///
906    /// Uses a transparent container and no border.
907    pub fn text(&self) -> Self {
908        self.variant(ButtonVariant::Text)
909    }
910}
911
912#[cfg(test)]
913mod tests {
914    use tessera_ui::{
915        HeadlessConfig, HeadlessRenderer, MOUSE_POINTER_ID, PressKeyEventType, PxPosition, State,
916        modifier::FocusModifierExt as _, remember,
917    };
918
919    use crate::{
920        column::column,
921        icon::icon,
922        material_icons::filled,
923        modifier::SemanticsArgs,
924        spacer::spacer,
925        text::text,
926        theme::{ContentColor, material_theme},
927    };
928
929    use super::*;
930
931    const SIZES: [ButtonSize; 5] = [
932        ButtonSize::ExtraSmall,
933        ButtonSize::Small,
934        ButtonSize::Medium,
935        ButtonSize::Large,
936        ButtonSize::ExtraLarge,
937    ];
938
939    #[derive(Clone, Copy, Default)]
940    struct Settings {
941        focus_reference: bool,
942        presentation_ripple: Option<RipplePresentation>,
943        presentation_state: Option<InteractionVisualState>,
944        disabled: bool,
945        variant: ButtonVariant,
946        size: Option<ButtonSize>,
947        color: Option<Color>,
948        content: Option<Color>,
949        shape: ButtonShape,
950        selected: Option<bool>,
951        custom_shape: Option<Shape>,
952    }
953
954    #[derive(Clone, Default, PartialEq)]
955    struct FixtureProbes {
956        focus_reference: tessera_ui::testing::TestProbe<Option<tessera_ui::PxRect>>,
957        settings: tessera_ui::testing::TestProbe<Option<State<Settings>>>,
958        interactions: tessera_ui::testing::TestProbe<[Option<State<InteractionState>>; 5]>,
959        activated: tessera_ui::testing::TestProbe<Option<State<Vec<usize>>>>,
960        clicks: tessera_ui::testing::TestProbe<Option<State<usize>>>,
961        styles: tessera_ui::testing::TestProbe<[Option<TextStyle>; 5]>,
962        colors: tessera_ui::testing::TestProbe<[Option<Color>; 5]>,
963    }
964
965    #[tessera]
966    fn label(index: usize, probe: FixtureProbes) {
967        let style = use_context::<TextStyle>().expect("button text style").get();
968        probe.styles.with_mut(|styles| styles[index] = Some(style));
969        let color = use_context::<ContentColor>().unwrap().get().current;
970        probe.colors.with_mut(|colors| colors[index] = Some(color));
971        text("Go");
972    }
973
974    #[tessera]
975    fn icon_probe(index: usize) {
976        boxed()
977            .modifier(Modifier::new().semantics(SemanticsArgs {
978                role: Some(Role::Image),
979                label: Some(format!("Icon {index}")),
980                ..Default::default()
981            }))
982            .children(|| {
983                icon().painter(filled::ADD_SVG);
984            });
985    }
986
987    #[tessera]
988    fn fixture(probe: FixtureProbes) {
989        let settings = remember(Settings::default);
990        let clicks = remember(|| 0usize);
991        let activated = remember(Vec::<usize>::new);
992        probe.activated.set(Some(activated));
993        probe.settings.set(Some(settings));
994        probe.clicks.set(Some(clicks));
995        material_theme().child(move || {
996            let probe = probe.clone();
997            boxed()
998                .modifier(Modifier::new().fill_max_size().background(Color::WHITE))
999                .children(move || {
1000                    let probe = probe.clone();
1001                    column()
1002                        .modifier(Modifier::new().offset(
1003                            Dp(if settings.with(|settings| settings.focus_reference) {
1004                                8.0
1005                            } else {
1006                                0.0
1007                            }),
1008                            Dp(0.0),
1009                        ))
1010                        .children(move || {
1011                            let probe = probe.clone();
1012                            for (index, size) in SIZES.into_iter().enumerate() {
1013                                let value = settings.get();
1014                                let interaction = remember(InteractionState::new);
1015                                probe
1016                                    .interactions
1017                                    .with_mut(|states| states[index] = Some(interaction));
1018                                button()
1019                                    .size(if index == 2 {
1020                                        value.size.unwrap_or(size)
1021                                    } else {
1022                                        size
1023                                    })
1024                                    .modifier(Modifier::new().width(Dp(260.0)))
1025                                    .variant(value.variant)
1026                                    .shape_style(value.shape)
1027                                    .shape_optional(value.custom_shape)
1028                                    .selected_optional(value.selected)
1029                                    .interaction_state(interaction)
1030                                    .presentation_state_optional(if index == 2 {
1031                                        value.presentation_state
1032                                    } else {
1033                                        None
1034                                    })
1035                                    .presentation_ripple_optional(if index == 2 {
1036                                        value.presentation_ripple
1037                                    } else {
1038                                        None
1039                                    })
1040                                    .color_optional(value.color)
1041                                    .content_color_optional(value.content)
1042                                    .enabled(!value.disabled)
1043                                    .accessibility_label(format!("Button {index}"))
1044                                    .on_click(move || {
1045                                        clicks.with_mut(|value| *value += 1);
1046                                        activated.with_mut(|items| items.push(index));
1047                                        settings.with_mut(|settings| {
1048                                            if let Some(selected) = &mut settings.selected {
1049                                                *selected = !*selected;
1050                                            }
1051                                        });
1052                                    })
1053                                    .leading_icon(move || {
1054                                        icon_probe(index);
1055                                    })
1056                                    .trailing_icon(move || {
1057                                        icon_probe(index + 5);
1058                                    })
1059                                    .child({
1060                                        let probe = probe.clone();
1061                                        move || label(index, probe.clone())
1062                                    });
1063                                spacer().modifier(Modifier::new().height(Dp(8.0)));
1064                            }
1065                            if settings.with(|settings| settings.focus_reference) {
1066                                let reference_modifier = Modifier::new()
1067                                    .size(Dp(270.0), Dp(66.0))
1068                                    .on_globally_positioned({
1069                                        let probe = probe.clone();
1070                                        move |bounds| probe.focus_reference.set(Some(bounds))
1071                                    });
1072                                surface()
1073                                    .shape(Shape::CAPSULE)
1074                                    .style(SurfaceStyle::Filled {
1075                                        color: Color::WHITE,
1076                                    })
1077                                    .show_state_layer(false)
1078                                    .presentation_state_optional(
1079                                        settings.with(|settings| settings.presentation_state),
1080                                    )
1081                                    .presentation_ripple_optional(
1082                                        settings.with(|settings| settings.presentation_ripple),
1083                                    )
1084                                    .modifier(reference_modifier);
1085                            }
1086                        });
1087                });
1088        });
1089    }
1090
1091    fn bounds(
1092        tree: &tessera_ui::accesskit::TreeUpdate,
1093        index: usize,
1094    ) -> tessera_ui::accesskit::Rect {
1095        tree.nodes
1096            .iter()
1097            .find(|(_, node)| node.label() == Some(format!("Button {index}").as_str()))
1098            .expect("button node")
1099            .1
1100            .bounds()
1101            .expect("button bounds")
1102    }
1103
1104    fn click(renderer: &mut HeadlessRenderer, rect: tessera_ui::accesskit::Rect) {
1105        let point = PxPosition::from_f32_xy(
1106            ((rect.x0 + rect.x1) * 0.5) as f32,
1107            ((rect.y0 + rect.y1) * 0.5) as f32,
1108        );
1109        renderer.pointer_move(MOUSE_POINTER_ID, point);
1110        renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1111        renderer.step_frame();
1112        renderer.pointer_release(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1113        renderer.render_frames(8);
1114    }
1115
1116    fn check_sizes(samples: u32) {
1117        let probe = FixtureProbes::default();
1118        let observed = probe.clone();
1119        let mut renderer = HeadlessRenderer::new(
1120            Box::new(move || fixture(observed.clone())),
1121            vec![Box::new(crate::TesseraComponents)],
1122            HeadlessConfig {
1123                width: 400,
1124                height: 640,
1125                sample_count: samples,
1126                ..Default::default()
1127            },
1128        )
1129        .expect("button renderer");
1130        renderer.render_frames(8);
1131        let tree = renderer
1132            .accessibility_snapshot()
1133            .expect("button accessibility");
1134        let pixels = renderer.read_pixels().expect("button pixels");
1135        if let Some(directory) = std::env::var_os("TESSERA_BUTTON_QA_DIR") {
1136            let directory = std::path::PathBuf::from(directory);
1137            std::fs::create_dir_all(&directory).unwrap();
1138            tessera_ui::headless::write_png_file(
1139                directory.join(format!("button-sizes-msaa{samples}.png")),
1140                400,
1141                640,
1142                &pixels,
1143            )
1144            .unwrap();
1145            std::fs::write(
1146                directory.join(format!("button-sizes-msaa{samples}.json")),
1147                renderer.snapshot().to_string(),
1148            )
1149            .unwrap();
1150        }
1151        for (index, height) in [32usize, 40, 56, 96, 176].into_iter().enumerate() {
1152            let rect = bounds(&tree, index);
1153            assert_eq!(rect.width(), 260.0);
1154            assert_eq!(rect.height(), height.max(48) as f64);
1155            let ink: Vec<_> = (rect.y0 as usize..rect.y1 as usize)
1156                .filter(|y| {
1157                    let start = (y * 400 + 130) * 4;
1158                    let background = (y * 400 + 300) * 4;
1159                    pixels[start..start + 3] != pixels[background..background + 3]
1160                })
1161                .collect();
1162            assert_eq!(
1163                ink.last().unwrap() - ink.first().unwrap() + 1,
1164                height,
1165                "visual height of size {index}; style {:?}",
1166                probe.styles.with(|styles| styles[index])
1167            );
1168            for icon in [index, index + 5] {
1169                let icon_bounds = tree
1170                    .nodes
1171                    .iter()
1172                    .find(|(_, node)| node.label() == Some(format!("Icon {icon}").as_str()))
1173                    .expect("sized icon")
1174                    .1
1175                    .bounds()
1176                    .unwrap();
1177                assert_eq!(icon_bounds.width(), [20.0, 20.0, 24.0, 32.0, 40.0][index]);
1178                assert_eq!(icon_bounds.height(), icon_bounds.width());
1179            }
1180            let style = probe.styles.with(|styles| styles[index].unwrap());
1181            let label = tree
1182                .nodes
1183                .iter()
1184                .filter(|(_, node)| node.label() == Some("Go"))
1185                .filter_map(|(_, node)| node.bounds())
1186                .find(|bounds| bounds.y0 >= rect.y0 && bounds.y1 <= rect.y1)
1187                .expect("button label");
1188            assert!(label.x0 >= rect.x0 && label.x1 <= rect.x1);
1189            let leading = tree
1190                .nodes
1191                .iter()
1192                .find(|(_, node)| node.label() == Some(format!("Icon {index}").as_str()))
1193                .and_then(|(_, node)| node.bounds())
1194                .expect("leading icon");
1195            assert_eq!(
1196                label.x0 - leading.x1,
1197                [4.0, 8.0, 8.0, 12.0, 16.0][index],
1198                "icon-label gap of size {index}"
1199            );
1200            assert_eq!(label.height(), [20.0, 20.0, 24.0, 32.0, 80.0][index]);
1201            assert_eq!(style.font_size, Dp([14.0, 14.0, 16.0, 24.0, 32.0][index]));
1202            assert_eq!(
1203                style.line_height,
1204                Some(Dp([20.0, 20.0, 24.0, 32.0, 40.0][index]))
1205            );
1206        }
1207        let settings = probe.settings.get().unwrap();
1208        settings.with_mut(|settings| settings.variant = ButtonVariant::Elevated);
1209        renderer.render_frames(8);
1210        let scheme = MaterialTheme::default().color_scheme;
1211        probe.colors.with(|colors| {
1212            assert_eq!(
1213                *colors,
1214                [Some(scheme.primary); 5],
1215                "elevated labels retain their primary color"
1216            )
1217        });
1218        let elevated = renderer.read_pixels().unwrap();
1219        let edge = (2 * 400 + 130) * 4;
1220        let background = (2 * 400 + 300) * 4;
1221        let fringe: u32 = elevated[edge..edge + 3]
1222            .iter()
1223            .zip(&elevated[background..background + 3])
1224            .map(|(a, b)| u32::from(a.abs_diff(*b)))
1225            .sum();
1226        assert!(
1227            fringe <= 9,
1228            "the shadow must follow the 32dp visual container, not the 48dp touch target: {fringe}"
1229        );
1230        settings.with_mut(|settings| settings.color = Some(scheme.secondary_container));
1231        renderer.render_frames(8);
1232        probe.colors.with(|colors| {
1233            assert_eq!(
1234                *colors,
1235                [Some(scheme.on_secondary_container); 5],
1236                "custom containers infer a matching content color"
1237            )
1238        });
1239        settings.with_mut(|settings| settings.content = Some(Color::RED));
1240        renderer.render_frames(8);
1241        probe.colors.with(|colors| {
1242            assert_eq!(
1243                *colors,
1244                [Some(Color::RED); 5],
1245                "explicit content color wins"
1246            )
1247        });
1248        settings.with_mut(|settings| {
1249            settings.variant = ButtonVariant::Filled;
1250            settings.color = None;
1251            settings.content = None;
1252        });
1253        renderer.render_frames(8);
1254        let clicks = probe.clicks.get().unwrap();
1255        click(&mut renderer, bounds(&tree, 0));
1256        assert_eq!(clicks.get(), 1);
1257        settings.with_mut(|settings| settings.disabled = true);
1258        renderer.render_frames(8);
1259        let disabled = renderer.accessibility_snapshot().unwrap();
1260        for index in 0..5 {
1261            assert_eq!(
1262                bounds(&disabled, index),
1263                bounds(&tree, index),
1264                "disabling preserves layout"
1265            );
1266        }
1267        click(&mut renderer, bounds(&disabled, 0));
1268        assert_eq!(clicks.get(), 1);
1269        settings.with_mut(|settings| {
1270            settings.disabled = false;
1271            settings.variant = ButtonVariant::Outlined;
1272        });
1273        renderer.pointer_move(MOUSE_POINTER_ID, PxPosition::from_f32_xy(390.0, 600.0));
1274        renderer.render_frames(120);
1275        probe.colors.with(|colors| {
1276            assert_eq!(
1277                *colors,
1278                [Some(scheme.on_surface_variant); 5],
1279                "outlined labels use on-surface-variant"
1280            )
1281        });
1282        let outlined = renderer.read_pixels().unwrap();
1283        for (index, width) in [1usize, 1, 1, 2, 3].into_iter().enumerate() {
1284            let rect = bounds(&tree, index);
1285            let y = ((rect.y0 + rect.y1) * 0.5) as usize;
1286            let start = (y * 400 + width - 1) * 4;
1287            let contrast: u32 = outlined[start..start + 3]
1288                .iter()
1289                .map(|channel| 255 - u32::from(*channel))
1290                .sum();
1291            assert!(
1292                contrast > 35,
1293                "outline thickness for size {index}: {contrast}"
1294            );
1295            let inner = (y * 400 + width + 2) * 4;
1296            let fill = (y * 400 + 50) * 4;
1297            assert_eq!(&outlined[inner..inner + 4], &outlined[fill..fill + 4]);
1298        }
1299        settings.with_mut(|settings| settings.size = Some(ButtonSize::ExtraLarge));
1300        renderer.render_frames(8);
1301        assert_eq!(
1302            bounds(&renderer.accessibility_snapshot().unwrap(), 2).height(),
1303            176.0
1304        );
1305        assert_eq!(
1306            probe.styles.with(|styles| styles[2].unwrap().font_size),
1307            Dp(32.0)
1308        );
1309        let changed = renderer.accessibility_snapshot().unwrap();
1310        let icon = changed
1311            .nodes
1312            .iter()
1313            .find(|(_, node)| node.label() == Some("Icon 2"))
1314            .unwrap()
1315            .1
1316            .bounds()
1317            .unwrap();
1318        assert_eq!(icon.width(), 40.0);
1319        assert_eq!(icon.height(), 40.0);
1320    }
1321
1322    #[test]
1323    fn shape_motion_keeps_velocity_and_resolves_against_actual_bounds() {
1324        use tessera_ui::{Px, PxSize};
1325
1326        for size in SIZES {
1327            for style in [ButtonShape::Round, ButtonShape::Square] {
1328                let (fraction, radius) = ButtonMotionTarget::corner(size, style, false, false);
1329                let target = ButtonMotionTarget {
1330                    fraction,
1331                    radius,
1332                    elevation: 0.0,
1333                    outline: 1.0,
1334                    toggle: false,
1335                };
1336                let mut motion = ButtonMotion::new(target, 0);
1337                let (fraction, radius) = ButtonMotionTarget::corner(size, style, false, true);
1338                motion.retarget(
1339                    ButtonMotionTarget {
1340                        fraction,
1341                        radius,
1342                        ..target
1343                    },
1344                    0,
1345                );
1346                motion.advance(33_333_334);
1347                assert!(!motion.is_idle());
1348                let value = motion.fraction.value();
1349                let velocity = motion.fraction.velocity();
1350                motion.retarget(target, 33_333_334);
1351                assert_eq!(motion.fraction.value(), value);
1352                assert_eq!(motion.fraction.velocity(), velocity);
1353                for frame in 3..150 {
1354                    motion.advance(frame * 16_666_667);
1355                }
1356                assert!(motion.is_idle());
1357                for height in [16, 40, 200] {
1358                    let bounds = PxSize::new(Px(300), Px(height));
1359                    let Shape::RoundedRectangle { top_left, .. } = motion.shape() else {
1360                        unreachable!()
1361                    };
1362                    let expected = if style == ButtonShape::Round {
1363                        height as f32 / 2.0
1364                    } else {
1365                        size.square_radius()
1366                            .to_pixels_f32()
1367                            .min(height as f32 / 2.0)
1368                    };
1369                    assert_eq!(top_left.resolve(bounds), (expected, 2.0));
1370                }
1371                let (fraction, radius) = ButtonMotionTarget::corner(size, style, true, false);
1372                motion.retarget(
1373                    ButtonMotionTarget {
1374                        fraction,
1375                        radius,
1376                        toggle: true,
1377                        ..target
1378                    },
1379                    2_500_000_000,
1380                );
1381                for frame in 151..300 {
1382                    motion.advance(frame * 16_666_667);
1383                }
1384                assert!(motion.is_idle());
1385                assert_eq!(
1386                    motion.fraction.value(),
1387                    if style == ButtonShape::Round {
1388                        0.0
1389                    } else {
1390                        0.5
1391                    }
1392                );
1393            }
1394        }
1395    }
1396
1397    fn check_shape_and_toggle_input(samples: u32) {
1398        let probe = FixtureProbes::default();
1399        let observed = probe.clone();
1400        let mut renderer = HeadlessRenderer::new(
1401            Box::new(move || fixture(observed.clone())),
1402            vec![Box::new(crate::TesseraComponents)],
1403            HeadlessConfig {
1404                width: 400,
1405                height: 640,
1406                sample_count: samples,
1407                ..Default::default()
1408            },
1409        )
1410        .unwrap();
1411        renderer.render_frames(8);
1412        let initial = renderer.accessibility_snapshot().unwrap();
1413        let round = renderer.read_pixels().unwrap();
1414        let settings = probe.settings.get().unwrap();
1415        settings.with_mut(|settings| {
1416            settings.shape = ButtonShape::Square;
1417            settings.selected = Some(false);
1418        });
1419        renderer.render_frames(120);
1420        let square = renderer.read_pixels().unwrap();
1421        assert_ne!(round, square, "square preset changes visible corners");
1422        let before = renderer.accessibility_snapshot().unwrap();
1423        let find = |tree: &tessera_ui::accesskit::TreeUpdate| {
1424            tree.nodes
1425                .iter()
1426                .find(|(_, node)| node.label() == Some("Button 2"))
1427                .unwrap()
1428                .clone()
1429        };
1430        let (identity, node) = find(&before);
1431        assert_eq!(node.role(), Role::Button);
1432        assert_eq!(node.toggled(), Some(Toggled::False));
1433        assert!(node.supports_action(tessera_ui::accesskit::Action::Click));
1434        let rect = bounds(&before, 2);
1435        let point = PxPosition::from_f32_xy(
1436            ((rect.x0 + rect.x1) / 2.0) as f32,
1437            ((rect.y0 + rect.y1) / 2.0) as f32,
1438        );
1439        renderer.pointer_move(MOUSE_POINTER_ID, point);
1440        renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1441        renderer.render_frames(20);
1442        assert!(
1443            probe
1444                .interactions
1445                .with(|states| states[2].unwrap())
1446                .with(InteractionState::is_pressed)
1447        );
1448        assert_eq!(probe.clicks.get().unwrap().get(), 0);
1449        assert_ne!(square, renderer.read_pixels().unwrap());
1450        renderer.pointer_release(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1451        renderer.step_frame();
1452        renderer.pointer_move(MOUSE_POINTER_ID, PxPosition::from_f32_xy(390.0, 630.0));
1453        renderer.render_frames(120);
1454        assert_eq!(settings.get().selected, Some(true));
1455        assert_eq!(probe.clicks.get().unwrap().get(), 1);
1456        let selected = renderer.accessibility_snapshot().unwrap();
1457        let (selected_identity, node) = find(&selected);
1458        assert_eq!(identity, selected_identity);
1459        assert_eq!(node.toggled(), Some(Toggled::True));
1460        for index in 0..5 {
1461            assert_eq!(bounds(&initial, index), bounds(&selected, index));
1462        }
1463        let scheme = MaterialTheme::default().color_scheme;
1464        probe
1465            .colors
1466            .with(|colors| assert_eq!(*colors, [Some(scheme.on_primary); 5]));
1467        settings.with_mut(|settings| settings.disabled = true);
1468        renderer.render_frames(120);
1469        let disabled = renderer.accessibility_snapshot().unwrap();
1470        let (disabled_identity, node) = find(&disabled);
1471        assert_eq!(identity, disabled_identity);
1472        assert_eq!(node.toggled(), Some(Toggled::True));
1473        assert!(node.is_disabled());
1474        assert!(!node.supports_action(tessera_ui::accesskit::Action::Click));
1475        click(&mut renderer, rect);
1476        assert_eq!(probe.clicks.get().unwrap().get(), 1);
1477        settings.with_mut(|settings| {
1478            settings.disabled = false;
1479            settings.selected = None;
1480        });
1481        renderer.render_frames(120);
1482        assert_eq!(
1483            find(&renderer.accessibility_snapshot().unwrap())
1484                .1
1485                .toggled(),
1486            None
1487        );
1488        renderer.pointer_move(MOUSE_POINTER_ID, point);
1489        renderer.step_frame();
1490        let pressed_at = tessera_ui::time::Instant::now();
1491        renderer.inject_pointer(tessera_ui::PointerChange::new(
1492            pressed_at,
1493            MOUSE_POINTER_ID,
1494            tessera_ui::CursorEventContent::Pressed(PressKeyEventType::Left),
1495            tessera_ui::GestureState::TapCandidate,
1496        ));
1497        renderer.step_frame();
1498        renderer.inject_pointer(tessera_ui::PointerChange::new(
1499            pressed_at + std::time::Duration::from_secs(2),
1500            MOUSE_POINTER_ID,
1501            tessera_ui::CursorEventContent::Released(PressKeyEventType::Left),
1502            tessera_ui::GestureState::TapCandidate,
1503        ));
1504        renderer.step_frame();
1505        assert_eq!(
1506            probe.clicks.get().unwrap().get(),
1507            2,
1508            "an ordinary button still activates after a long hold"
1509        );
1510        renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1511        renderer.step_frame();
1512        let outside = PxPosition::from_f32_xy(390.0, 630.0);
1513        renderer.pointer_release(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1514        renderer.pointer_move(MOUSE_POINTER_ID, outside);
1515        renderer.step_frame();
1516        assert_eq!(
1517            probe.clicks.get().unwrap().get(),
1518            3,
1519            "release inside survives later movement in the same frame"
1520        );
1521        let button_point = |index| {
1522            let rect = bounds(&initial, index);
1523            PxPosition::from_f32_xy(
1524                ((rect.x0 + rect.x1) / 2.0) as f32,
1525                ((rect.y0 + rect.y1) / 2.0) as f32,
1526            )
1527        };
1528        for index in [0, 4] {
1529            let position = button_point(index);
1530            renderer.pointer_move(MOUSE_POINTER_ID, position);
1531            renderer.pointer_press(MOUSE_POINTER_ID, position, PressKeyEventType::Left);
1532            renderer.pointer_release(MOUSE_POINTER_ID, position, PressKeyEventType::Left);
1533        }
1534        renderer.pointer_move(MOUSE_POINTER_ID, outside);
1535        renderer.render_frames(2);
1536        assert_eq!(
1537            probe.activated.get().unwrap().get(),
1538            [2, 2, 2, 0, 4],
1539            "rapid queued clicks target each button in order"
1540        );
1541        renderer.pointer_press(11, button_point(0), PressKeyEventType::Left);
1542        renderer.pointer_press(22, button_point(4), PressKeyEventType::Left);
1543        renderer.render_frames(2);
1544        for index in [0, 4] {
1545            assert!(
1546                probe
1547                    .interactions
1548                    .with(|states| states[index].unwrap())
1549                    .with(InteractionState::is_pressed),
1550                "independent held pointer on button {index}"
1551            );
1552        }
1553        renderer.pointer_release(11, button_point(0), PressKeyEventType::Left);
1554        renderer.pointer_release(22, button_point(4), PressKeyEventType::Left);
1555        renderer.pointer_move(MOUSE_POINTER_ID, outside);
1556        renderer.render_frames(2);
1557        assert_eq!(probe.activated.get().unwrap().get(), [2, 2, 2, 0, 4, 0, 4]);
1558        if let Some(directory) = std::env::var_os("TESSERA_BUTTON_QA_DIR") {
1559            let directory = std::path::PathBuf::from(directory);
1560            std::fs::create_dir_all(&directory).unwrap();
1561            tessera_ui::headless::write_png_file(
1562                directory.join(format!("button-square-msaa{samples}.png")),
1563                400,
1564                640,
1565                &square,
1566            )
1567            .unwrap();
1568        }
1569    }
1570
1571    #[test]
1572    #[ignore = "requires a native GPU adapter"]
1573    fn expressive_shape_toggle_semantics_and_input_msaa1() {
1574        check_shape_and_toggle_input(1);
1575    }
1576
1577    #[test]
1578    #[ignore = "requires a native GPU adapter"]
1579    fn expressive_shape_toggle_semantics_and_input_msaa4() {
1580        check_shape_and_toggle_input(4);
1581    }
1582
1583    #[test]
1584    #[ignore = "requires a native GPU adapter"]
1585    fn expressive_size_geometry_typography_outline_and_disabled_layout_msaa1() {
1586        check_sizes(1);
1587    }
1588
1589    #[test]
1590    #[ignore = "requires a native GPU adapter"]
1591    fn expressive_size_geometry_typography_outline_and_disabled_layout_msaa4() {
1592        check_sizes(4);
1593    }
1594    #[test]
1595    #[ignore = "requires a native GPU adapter"]
1596    fn controlled_presentation_matches_native_states_without_taking_focus() {
1597        crate::test_utils::run_isolated_test(
1598            "button::tests::controlled_presentation_matches_native_states_without_taking_focus",
1599            || {
1600                use tessera_ui::accesskit::{Action, ActionRequest, TreeId};
1601
1602                let probe = FixtureProbes::default();
1603                let observed = probe.clone();
1604                let mut renderer = HeadlessRenderer::new(
1605                    Box::new(move || fixture(observed.clone())),
1606                    vec![Box::new(crate::TesseraComponents)],
1607                    HeadlessConfig {
1608                        width: 400,
1609                        height: 640,
1610                        frame_time: std::time::Duration::from_millis(100),
1611                        ..Default::default()
1612                    },
1613                )
1614                .unwrap();
1615                renderer.render_frames(20);
1616                let settings = probe.settings.get().unwrap();
1617                settings.with_mut(|settings| settings.focus_reference = true);
1618                renderer.render_frames(20);
1619                let tree = renderer.accessibility_snapshot().unwrap();
1620                let id = tree
1621                    .nodes
1622                    .iter()
1623                    .find(|(_, node)| node.label() == Some("Button 2"))
1624                    .unwrap()
1625                    .0;
1626                let rect = bounds(&tree, 2);
1627                let point = PxPosition::from_f32_xy(
1628                    ((rect.x0 + rect.x1) / 2.0) as f32,
1629                    ((rect.y0 + rect.y1) / 2.0) as f32,
1630                );
1631                let outside = PxPosition::from_f32_xy((400 - 2) as f32, (640 - 2) as f32);
1632                let crop = |pixels: &[u8]| {
1633                    let mut result = Vec::new();
1634                    for y in rect.y0 as usize..rect.y1 as usize {
1635                        let start = (y * 400 + rect.x0 as usize) * 4;
1636                        result.extend_from_slice(&pixels[start..start + rect.width() as usize * 4]);
1637                    }
1638                    result
1639                };
1640                let focus_crop = |pixels: &[u8]| {
1641                    let mut result = Vec::new();
1642                    for y in rect.y0 as usize - 6..rect.y1 as usize + 6 {
1643                        let start = (y * 400 + rect.x0 as usize - 6) * 4;
1644                        result.extend_from_slice(
1645                            &pixels[start..start + (rect.width() as usize + 12) * 4],
1646                        );
1647                    }
1648                    result
1649                };
1650                let mut focus_preview = None;
1651                let rest_pixels = renderer.read_pixels().unwrap();
1652                let rest = crop(&rest_pixels);
1653                let mut previews = Vec::new();
1654                for presentation in [
1655                    InteractionVisualState::Hover,
1656                    InteractionVisualState::Focus,
1657                    InteractionVisualState::Pressed,
1658                ] {
1659                    settings.with_mut(|settings| settings.presentation_state = Some(presentation));
1660                    renderer.render_frames(20);
1661                    let preview = crop(&renderer.read_pixels().unwrap());
1662                    assert_ne!(
1663                        preview, rest,
1664                        "presentation changes native appearance: {presentation:?}"
1665                    );
1666                    let live = probe.interactions.with(|states| states[2].unwrap()).get();
1667                    assert_eq!(live, InteractionState::new());
1668                    assert_ne!(renderer.accessibility_snapshot().unwrap().focus, id);
1669                    assert_eq!(probe.clicks.get().unwrap().get(), 0);
1670
1671                    if presentation == InteractionVisualState::Focus {
1672                        let pixels = renderer.read_pixels().unwrap();
1673                        focus_preview = Some(focus_crop(&pixels));
1674                        let mut changed = Vec::new();
1675                        for y in rect.y0 as usize - 6..rect.y1 as usize + 6 {
1676                            for x in rect.x0 as usize - 6..rect.x1 as usize + 6 {
1677                                let offset = (y * 400 + x) * 4;
1678                                if pixels[offset..offset + 4] != rest_pixels[offset..offset + 4] {
1679                                    changed.push((x, y));
1680                                }
1681                            }
1682                        }
1683                        let ink = (
1684                            changed.iter().map(|p| p.0).min().unwrap(),
1685                            changed.iter().map(|p| p.1).min().unwrap(),
1686                            changed.iter().map(|p| p.0).max().unwrap() + 1,
1687                            changed.iter().map(|p| p.1).max().unwrap() + 1,
1688                        );
1689                        assert_eq!(
1690                            ink,
1691                            (
1692                                rect.x0 as usize - 5,
1693                                rect.y0 as usize - 5,
1694                                rect.x1 as usize + 5,
1695                                rect.y1 as usize + 5
1696                            ),
1697                            "native focus outline extends five pixels beyond the container"
1698                        );
1699                    }
1700                    previews.push(preview);
1701                    settings.with_mut(|settings| settings.presentation_state = None);
1702                    renderer.render_frames(20);
1703                    assert_eq!(
1704                        crop(&renderer.read_pixels().unwrap()),
1705                        rest,
1706                        "clearing retained presentation restores native rest"
1707                    );
1708                }
1709                settings.with_mut(|settings| {
1710                    settings.presentation_state = Some(InteractionVisualState::Pressed);
1711                    settings.presentation_ripple = Some(RipplePresentation {
1712                        origin: [0.7, 0.9],
1713                        radius: 0.4,
1714                        opacity: 0.2,
1715                    });
1716                });
1717                renderer.render_frames(20);
1718                let sampled = crop(&renderer.read_pixels().unwrap());
1719                let pixels = renderer.read_pixels().unwrap();
1720                let reference = probe.focus_reference.get().unwrap();
1721                let x = (reference.x.0 as f32 + reference.width.0 as f32 * 0.7) as usize;
1722                let y = (reference.y.0 as f32 + reference.height.0 as f32 * 0.9) as usize;
1723                assert!(
1724                    pixels[(y * 400 + x) * 4..(y * 400 + x) * 4 + 3]
1725                        .iter()
1726                        .any(|value| *value < 240),
1727                    "static ripple draws on a noninteractive surface with its state layer disabled"
1728                );
1729
1730                assert_ne!(
1731                    sampled, previews[2],
1732                    "explicit native ripple sample changes pressed pixels"
1733                );
1734                renderer.render_frames(20);
1735                assert_eq!(
1736                    crop(&renderer.read_pixels().unwrap()),
1737                    sampled,
1738                    "presentation ripple remains static across frame timestamps"
1739                );
1740                assert_eq!(
1741                    renderer.work().phase(tessera_ui::WorkPhase::Paint).count(),
1742                    0
1743                );
1744                settings.with_mut(|settings| settings.presentation_ripple = None);
1745                renderer.render_frames(20);
1746                assert_eq!(crop(&renderer.read_pixels().unwrap()), previews[2]);
1747                settings.with_mut(|settings| {
1748                    settings.presentation_ripple = Some(RipplePresentation {
1749                        origin: [f32::NAN, f32::INFINITY],
1750                        radius: -1.0,
1751                        opacity: f32::NAN,
1752                    })
1753                });
1754                renderer.render_frames(20);
1755                assert_eq!(
1756                    crop(&renderer.read_pixels().unwrap()),
1757                    previews[2],
1758                    "invalid samples safely disable the ripple"
1759                );
1760                settings.with_mut(|settings| {
1761                    settings.presentation_ripple = None;
1762                    settings.presentation_state = None;
1763                });
1764                renderer.render_frames(20);
1765                renderer.pointer_move(MOUSE_POINTER_ID, point);
1766                renderer.render_frames(20);
1767                assert_eq!(
1768                    crop(&renderer.read_pixels().unwrap()),
1769                    previews[0],
1770                    "preview hover matches actual pointer hover"
1771                );
1772                settings.with_mut(|settings| {
1773                    settings.presentation_state = Some(InteractionVisualState::Pressed)
1774                });
1775                renderer.render_frames(20);
1776                assert_eq!(
1777                    crop(&renderer.read_pixels().unwrap()),
1778                    previews[0],
1779                    "live hover overrides pressed presentation"
1780                );
1781                renderer.pointer_move(MOUSE_POINTER_ID, outside);
1782                settings.with_mut(|settings| settings.presentation_state = None);
1783                renderer.render_frames(20);
1784                assert!(renderer.dispatch_accessibility_action(ActionRequest {
1785                    action: Action::Focus,
1786                    target_tree: TreeId::ROOT,
1787                    target_node: id,
1788                    data: None,
1789                }));
1790                renderer.render_frames(20);
1791                assert_eq!(renderer.accessibility_snapshot().unwrap().focus, id);
1792                assert_eq!(
1793                    focus_crop(&renderer.read_pixels().unwrap()),
1794                    focus_preview.as_ref().unwrap().as_slice(),
1795                    "preview focus matches actual native focus"
1796                );
1797                settings.with_mut(|settings| {
1798                    settings.presentation_state = Some(InteractionVisualState::Hover)
1799                });
1800                renderer.render_frames(20);
1801                assert_eq!(
1802                    focus_crop(&renderer.read_pixels().unwrap()),
1803                    focus_preview.as_ref().unwrap().as_slice(),
1804                    "live focus overrides hover presentation"
1805                );
1806                settings.with_mut(|settings| settings.presentation_state = None);
1807                renderer.pointer_move(MOUSE_POINTER_ID, point);
1808                renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1809                renderer.render_frames(20);
1810                assert_eq!(
1811                    renderer.accessibility_snapshot().unwrap().focus,
1812                    id,
1813                    "pointer modality preserves actual focus ownership"
1814                );
1815                let pointer_pixels = renderer.read_pixels().unwrap();
1816                for y in rect.y0 as usize - 5..rect.y1 as usize + 5 {
1817                    for x in rect.x0 as usize - 5..rect.x1 as usize + 5 {
1818                        if x >= rect.x0 as usize
1819                            && x < rect.x1 as usize
1820                            && y >= rect.y0 as usize
1821                            && y < rect.y1 as usize
1822                        {
1823                            continue;
1824                        }
1825                        let offset = (y * 400 + x) * 4;
1826                        assert_eq!(
1827                            &pointer_pixels[offset..offset + 4],
1828                            &rest_pixels[offset..offset + 4],
1829                            "actual pointer press hides only the outside focus indicator"
1830                        );
1831                    }
1832                }
1833                renderer.pointer_release(MOUSE_POINTER_ID, outside, PressKeyEventType::Left);
1834                renderer.pointer_move(MOUSE_POINTER_ID, outside);
1835                renderer.render_frames(20);
1836                assert_eq!(renderer.accessibility_snapshot().unwrap().focus, id);
1837                assert!(renderer.dispatch_accessibility_action(ActionRequest {
1838                    action: Action::Focus,
1839                    target_tree: TreeId::ROOT,
1840                    target_node: id,
1841                    data: None,
1842                }));
1843                renderer.render_frames(20);
1844                assert_eq!(
1845                    focus_crop(&renderer.read_pixels().unwrap()),
1846                    focus_preview.as_ref().unwrap().as_slice(),
1847                    "explicit accessibility focus restores the ring on the same focused handle"
1848                );
1849                renderer.pointer_move(MOUSE_POINTER_ID, point);
1850                renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1851                renderer.render_frames(20);
1852                assert!(
1853                    probe
1854                        .interactions
1855                        .with(|states| states[2].unwrap())
1856                        .with(InteractionState::is_pressed)
1857                );
1858                tessera_ui::FocusManager::current().clear_focus(true);
1859                renderer.render_frames(20);
1860                let held = probe.interactions.with(|states| states[2].unwrap()).get();
1861                assert!(
1862                    held.is_pressed(),
1863                    "clearing keyboard focus preserves the actual held pointer press"
1864                );
1865                assert!(!held.is_focused());
1866                let mask = |pixels: &[u8]| {
1867                    pixels
1868                        .chunks_exact(4)
1869                        .map(|pixel| pixel[..3] != [255, 255, 255])
1870                        .collect::<Vec<_>>()
1871                };
1872                assert_eq!(
1873                    mask(&crop(&renderer.read_pixels().unwrap())),
1874                    mask(&previews[2]),
1875                    "pressed presentation uses the actual native pressed silhouette"
1876                );
1877                renderer.pointer_release(MOUSE_POINTER_ID, outside, PressKeyEventType::Left);
1878                renderer.pointer_move(MOUSE_POINTER_ID, outside);
1879                tessera_ui::FocusManager::current().clear_focus(true);
1880                renderer.render_frames(20);
1881                assert_eq!(probe.clicks.get().unwrap().get(), 0);
1882                settings.with_mut(|settings| {
1883                    settings.disabled = true;
1884                    settings.presentation_state = Some(InteractionVisualState::Pressed);
1885                });
1886                renderer.render_frames(20);
1887                let disabled = renderer.read_pixels().unwrap();
1888                settings.with_mut(|settings| settings.presentation_state = None);
1889                renderer.render_frames(20);
1890                assert_eq!(
1891                    renderer.read_pixels().unwrap(),
1892                    disabled,
1893                    "disabled styling overrides presentation"
1894                );
1895            },
1896        );
1897    }
1898}