1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum ButtonVariant {
33 #[default]
35 Filled,
36 Elevated,
38 Tonal,
40 Outlined,
42 Text,
44}
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub enum ButtonShape {
48 #[default]
50 Round,
51 Square,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum ButtonSize {
58 ExtraSmall,
60 #[default]
62 Small,
63 Medium,
65 Large,
67 ExtraLarge,
69}
70
71impl ButtonSize {
72 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 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 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 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 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 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 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
152pub struct ButtonDefaults;
154
155impl ButtonDefaults {
156 pub const PRESSED_ALPHA: f32 = MaterialAlpha::PRESSED;
158 pub const DISABLED_CONTAINER_ALPHA: f32 = MaterialAlpha::DISABLED_CONTAINER;
160 pub const DISABLED_CONTENT_ALPHA: f32 = MaterialAlpha::DISABLED_CONTENT;
162 pub const FILLED_DISABLED_CONTAINER_ALPHA: f32 = 0.1;
164 pub const DISABLED_LABEL_ALPHA: f32 = 0.38;
166 pub const MIN_WIDTH: Dp = Dp(58.0);
168 pub const MIN_HEIGHT: Dp = Dp(40.0);
170 pub const CONTENT_HORIZONTAL_PADDING: Dp = Dp(16.0);
172 pub const CONTENT_VERTICAL_PADDING: Dp = Dp(10.0);
174
175 pub fn disabled_container_color(scheme: &MaterialColorScheme) -> Color {
177 scheme
178 .on_surface
179 .with_alpha(Self::FILLED_DISABLED_CONTAINER_ALPHA)
180 }
181
182 pub fn disabled_content_color(scheme: &MaterialColorScheme) -> Color {
184 scheme
185 .on_surface_variant
186 .with_alpha(Self::DISABLED_LABEL_ALPHA)
187 }
188
189 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#[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 pub fn round(&self) -> Self {
867 self.shape_style(ButtonShape::Round)
868 }
869
870 pub fn square(&self) -> Self {
872 self.shape_style(ButtonShape::Square)
873 }
874
875 pub fn filled(&self) -> Self {
879 self.variant(ButtonVariant::Filled)
880 }
881
882 pub fn elevated(&self) -> Self {
886 self.variant(ButtonVariant::Elevated)
887 }
888
889 pub fn tonal(&self) -> Self {
894 self.variant(ButtonVariant::Tonal)
895 }
896
897 pub fn outlined(&self) -> Self {
901 self.variant(ButtonVariant::Outlined)
902 }
903
904 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}