1use std::rc::Rc;
180use std::time::Duration;
181
182use frust::authoring::text::{
183 FontWeight, LineHeight, TextContext, TextLayout, TextOverflow, TextStyle,
184};
185use frust::authoring::{
186 Action, AnyView, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color, CornerRadii,
187 CursorIcon, ErasedCallback, EventCtx, EventResult, InputEvent, Key, LayoutCtx, NamedKey,
188 PaintCtx, PaintScene, Point, PointerPhase, Rect, Role, SemanticsCtx, Size, View, Widget, any,
189 build_child, erase_callback, rebuild_child, route_event, teardown_child, visit_children,
190};
191use frust::{AnimationController, Curve, FrameTime, IconData, SpringDesc, Theme};
192use kurbo::{Affine, BezPath, RoundedRect, RoundedRectRadii, Shape, Vec2};
193
194use crate::icons;
195use crate::interaction::{
196 DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState,
197 MaterialHaptics,
198};
199use crate::menu::{MenuIcon, MenuNode, MenuSelection, menu, menu_panel, menu_selectable};
200use crate::overlay::OverlayAnchor;
201use crate::press::presses;
202use crate::sheet::{BottomSheetView, bottom_sheet};
203
204pub const SPLIT_MIN_TAP_TARGET: f64 = 48.0;
210
211pub const SPLIT_INNER_GAP: f64 = 2.0;
213
214pub const SPLIT_ELEVATED_INNER_GAP: f64 = 4.0;
218
219pub const CHEVRON_OPEN_TURNS: f64 = 0.5;
222
223pub const TRAILING_SELECTED_CORNER_PERCENT: f64 = 50.0;
226
227const FOCUS_RING_WIDTH: f64 = 2.0;
230
231const FOCUS_RING_GAP: f64 = 2.0;
234
235pub const SPLIT_FOCUS_RING_OUTSET: f64 = FOCUS_RING_GAP + FOCUS_RING_WIDTH;
239
240pub const CHEVRON_ROTATION_DURATION: Duration = Duration::from_millis(120);
244
245const CHEVRON_ROTATION_CURVE: Curve = Curve::EaseOut;
247
248pub const SPLIT_SHAPE_SPRING: SpringDesc = SpringDesc {
253 mass: 1.0,
254 stiffness: 380.0,
255 damping_ratio: 0.55,
256};
257
258const SHAPE_ANIM_PERIOD: Duration = Duration::from_millis(300);
262
263const FLING_VELOCITY: f64 = 4.0;
266
267const RETARGET_TOLERANCE: f64 = 0.1;
271
272const OVERSHOOT_LIMIT: f64 = 1.5;
275
276const OUTLINE_WIDTH: f64 = 1.0;
278
279const PATH_TOLERANCE: f64 = 0.1;
282
283const SHAPING_INK: Color = Color::BLACK;
286
287const DEFAULT_TRAILING_LABEL: &str = "Show menu";
290
291type TypeToken = (f32, f32, f32, FontWeight);
296
297const LABEL_SMALL: TypeToken = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
298const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
299const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
300const TITLE_MEDIUM: TypeToken = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
301const TITLE_LARGE: TypeToken = (22.0, 28.0, 0.0, FontWeight::REGULAR);
302
303#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
307pub enum SplitButtonSize {
308 Xs,
310 #[default]
312 Sm,
313 Md,
315 Lg,
317 Xl,
319}
320
321impl SplitButtonSize {
322 pub const ALL: [SplitButtonSize; 5] = [
324 SplitButtonSize::Xs,
325 SplitButtonSize::Sm,
326 SplitButtonSize::Md,
327 SplitButtonSize::Lg,
328 SplitButtonSize::Xl,
329 ];
330
331 pub fn height(self) -> f64 {
333 match self {
334 SplitButtonSize::Xs => 32.0,
335 SplitButtonSize::Sm => 40.0,
336 SplitButtonSize::Md => 56.0,
337 SplitButtonSize::Lg => 96.0,
338 SplitButtonSize::Xl => 136.0,
339 }
340 }
341
342 fn trailing_icon_size(self) -> f64 {
345 match self {
346 SplitButtonSize::Xs | SplitButtonSize::Sm => 22.0,
347 SplitButtonSize::Md => 26.0,
348 SplitButtonSize::Lg => 38.0,
349 SplitButtonSize::Xl => 50.0,
350 }
351 }
352
353 fn icon_size(self) -> f64 {
356 match self {
357 SplitButtonSize::Xs => 20.0,
358 SplitButtonSize::Sm | SplitButtonSize::Md => 24.0,
359 SplitButtonSize::Lg => 32.0,
360 SplitButtonSize::Xl => 40.0,
361 }
362 }
363
364 fn left_outer_padding(self) -> f64 {
367 match self {
368 SplitButtonSize::Xs => 12.0,
369 SplitButtonSize::Sm => 16.0,
370 SplitButtonSize::Md => 24.0,
371 SplitButtonSize::Lg => 48.0,
372 SplitButtonSize::Xl => 64.0,
373 }
374 }
375
376 fn icon_to_label_gap(self) -> f64 {
379 match self {
380 SplitButtonSize::Xs => 4.0,
381 SplitButtonSize::Sm | SplitButtonSize::Md => 8.0,
382 SplitButtonSize::Lg => 12.0,
383 SplitButtonSize::Xl => 16.0,
384 }
385 }
386
387 fn label_right_padding(self) -> f64 {
390 match self {
391 SplitButtonSize::Xs => 10.0,
392 SplitButtonSize::Sm => 12.0,
393 SplitButtonSize::Md => 24.0,
394 SplitButtonSize::Lg => 48.0,
395 SplitButtonSize::Xl => 64.0,
396 }
397 }
398
399 fn trailing_left_padding(self) -> f64 {
402 match self {
403 SplitButtonSize::Xs | SplitButtonSize::Sm => 13.0,
404 SplitButtonSize::Md => 15.0,
405 SplitButtonSize::Lg => 29.0,
406 SplitButtonSize::Xl => 43.0,
407 }
408 }
409
410 fn right_outer_padding(self) -> f64 {
413 self.trailing_left_padding()
414 }
415
416 fn side_padding_selected(self) -> f64 {
419 self.trailing_left_padding()
420 }
421
422 pub fn trailing_width(self) -> f64 {
427 self.trailing_width_when(false, false)
428 }
429
430 fn trailing_width_when(self, open: bool, circle: bool) -> f64 {
436 let base = if circle {
437 self.height()
438 } else if open {
439 self.trailing_width_selected()
440 } else {
441 self.trailing_width_unselected()
442 };
443 base.max(SPLIT_MIN_TAP_TARGET)
444 }
445
446 fn trailing_width_unselected(self) -> f64 {
448 self.trailing_left_padding() + self.trailing_icon_size() + self.right_outer_padding()
449 }
450
451 fn trailing_width_selected(self) -> f64 {
453 self.side_padding_selected() * 2.0 + self.trailing_icon_size()
454 }
455
456 fn allows_circle_trailing(self) -> bool {
459 matches!(
460 self,
461 SplitButtonSize::Md | SplitButtonSize::Lg | SplitButtonSize::Xl
462 )
463 }
464
465 fn menu_icon_offset(self) -> f64 {
469 match self {
470 SplitButtonSize::Xs | SplitButtonSize::Sm => -1.0,
471 SplitButtonSize::Md => -2.0,
472 SplitButtonSize::Lg => -3.0,
473 SplitButtonSize::Xl => -6.0,
474 }
475 }
476
477 fn inner_radius(self, theme: Option<&Theme>) -> f64 {
482 match theme {
483 Some(theme) => match self {
484 SplitButtonSize::Xs | SplitButtonSize::Sm | SplitButtonSize::Md => {
485 theme.shape.extra_small
486 }
487 SplitButtonSize::Lg => theme.shape.small,
488 SplitButtonSize::Xl => theme.shape.medium,
489 },
490 None => match self {
491 SplitButtonSize::Xs | SplitButtonSize::Sm | SplitButtonSize::Md => 4.0,
492 SplitButtonSize::Lg => 8.0,
493 SplitButtonSize::Xl => 12.0,
494 },
495 }
496 }
497
498 fn hovered_inner_radius(self) -> f64 {
502 match self {
503 SplitButtonSize::Xs => 8.0,
504 SplitButtonSize::Sm | SplitButtonSize::Md => 12.0,
505 SplitButtonSize::Lg | SplitButtonSize::Xl => 20.0,
506 }
507 }
508
509 fn pressed_radius(self) -> f64 {
512 match self {
513 SplitButtonSize::Xs | SplitButtonSize::Sm | SplitButtonSize::Md => 2.0,
514 SplitButtonSize::Lg => 4.0,
515 SplitButtonSize::Xl => 6.0,
516 }
517 }
518
519 fn square_outer_radius(self) -> f64 {
523 match self {
524 SplitButtonSize::Xs => 8.0,
525 SplitButtonSize::Sm => 10.0,
526 SplitButtonSize::Md => 14.0,
527 SplitButtonSize::Lg => 24.0,
528 SplitButtonSize::Xl => 34.0,
529 }
530 }
531
532 fn label_style(self, theme: Option<&Theme>) -> TextStyle {
537 let mut style = match theme {
538 Some(theme) => match self {
539 SplitButtonSize::Xs => theme.type_scale.label_small.clone(),
540 SplitButtonSize::Sm => theme.type_scale.label_medium.clone(),
541 SplitButtonSize::Md => theme.type_scale.label_large.clone(),
542 SplitButtonSize::Lg => theme.type_scale.title_medium.clone(),
543 SplitButtonSize::Xl => theme.type_scale.title_large.clone(),
544 },
545 None => {
546 let (size, line_height, letter_spacing, weight) = match self {
547 SplitButtonSize::Xs => LABEL_SMALL,
548 SplitButtonSize::Sm => LABEL_MEDIUM,
549 SplitButtonSize::Md => LABEL_LARGE,
550 SplitButtonSize::Lg => TITLE_MEDIUM,
551 SplitButtonSize::Xl => TITLE_LARGE,
552 };
553 let mut style = TextStyle::new(size, SHAPING_INK);
554 style.line_height = LineHeight::Absolute(line_height);
555 style.letter_spacing = letter_spacing;
556 style.weight = weight;
557 style
558 }
559 };
560 style.color = SHAPING_INK;
561 style
562 }
563}
564
565#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
568pub enum SplitButtonShape {
569 #[default]
571 Round,
572 Square,
574}
575
576#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
581pub enum SplitButtonVariant {
582 #[default]
584 Filled,
585 Tonal,
587 Elevated,
589 Outlined,
591}
592
593impl SplitButtonVariant {
594 pub const ALL: [SplitButtonVariant; 4] = [
596 SplitButtonVariant::Filled,
597 SplitButtonVariant::Tonal,
598 SplitButtonVariant::Elevated,
599 SplitButtonVariant::Outlined,
600 ];
601}
602
603#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
606pub enum SplitButtonTrailingAlignment {
607 #[default]
610 OpticalCenter,
611 GeometricCenter,
613}
614
615#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
619pub enum SplitButtonMenuStyle {
620 #[default]
622 Popup,
623 BottomSheet,
625}
626
627#[derive(Clone, Debug, PartialEq)]
636pub struct SplitButtonItem {
637 label: String,
638 value: Option<String>,
639 enabled: bool,
640 leading: Option<MenuIcon>,
641}
642
643pub fn split_button_item(label: impl Into<String>) -> SplitButtonItem {
645 SplitButtonItem {
646 label: label.into(),
647 value: None,
648 enabled: true,
649 leading: None,
650 }
651}
652
653impl SplitButtonItem {
654 pub fn value(mut self, value: impl Into<String>) -> Self {
656 self.value = Some(value.into());
657 self
658 }
659
660 pub fn enabled(mut self, enabled: bool) -> Self {
662 self.enabled = enabled;
663 self
664 }
665
666 pub fn leading(mut self, icon: impl Into<MenuIcon>) -> Self {
668 self.leading = Some(icon.into());
669 self
670 }
671
672 pub fn label(&self) -> &str {
674 &self.label
675 }
676
677 pub fn resolved_value(&self) -> &str {
679 self.value.as_deref().unwrap_or(&self.label)
680 }
681
682 fn to_menu_node(&self) -> MenuNode {
686 let mut node = menu_selectable(self.label.clone(), self.resolved_value().to_string())
687 .enabled(self.enabled);
688 if let Some(icon) = self.leading {
689 node = node.leading(icon);
690 }
691 node.into()
692 }
693}
694
695pub enum SplitButtonMenuRoute<State: 'static> {
699 Popup(AnyView<State>),
702 Sheet(BottomSheetView<State>),
705}
706
707const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
711const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
713const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
715const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
717const SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
719const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
721const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
723const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
726
727#[derive(Clone, Copy, Debug, PartialEq)]
729struct SegmentColors {
730 container: Color,
732 content: Color,
734 outline: Option<Color>,
736 focus_ring: Color,
738}
739
740fn with_alpha(color: Color, alpha: f32) -> Color {
742 let c = color.components;
743 Color::new([c[0], c[1], c[2], alpha])
744}
745
746fn resolve_colors(
750 theme: Option<&Theme>,
751 variant: SplitButtonVariant,
752 enabled: bool,
753) -> SegmentColors {
754 let (
755 primary,
756 on_primary,
757 secondary_container,
758 on_secondary_container,
759 container_low,
760 outline,
761 on_surface,
762 ) = match theme {
763 Some(theme) => {
764 let s = theme.scheme();
765 (
766 s.primary,
767 s.on_primary,
768 s.secondary_container,
769 s.on_secondary_container,
770 s.surface_container_low,
771 s.outline,
772 s.on_surface,
773 )
774 }
775 None => (
776 PRIMARY,
777 ON_PRIMARY,
778 SECONDARY_CONTAINER,
779 ON_SECONDARY_CONTAINER,
780 SURFACE_CONTAINER_LOW,
781 OUTLINE,
782 ON_SURFACE,
783 ),
784 };
785 let transparent = variant == SplitButtonVariant::Outlined;
786
787 if !enabled {
788 return SegmentColors {
789 container: if transparent {
790 Color::TRANSPARENT
791 } else {
792 with_alpha(on_surface, DISABLED_CONTAINER_OPACITY)
793 },
794 content: with_alpha(on_surface, DISABLED_CONTENT_OPACITY),
795 outline: transparent.then(|| with_alpha(on_surface, DISABLED_CONTAINER_OPACITY)),
796 focus_ring: primary,
797 };
798 }
799
800 let (container, content) = match variant {
801 SplitButtonVariant::Filled => (primary, on_primary),
802 SplitButtonVariant::Tonal => (secondary_container, on_secondary_container),
803 SplitButtonVariant::Elevated => (container_low, primary),
804 SplitButtonVariant::Outlined => (Color::TRANSPARENT, primary),
805 };
806 SegmentColors {
807 container,
808 content,
809 outline: transparent.then_some(outline),
810 focus_ring: primary,
811 }
812}
813
814fn elevation_dp(variant: SplitButtonVariant, enabled: bool, pressed: bool, hovered: bool) -> f64 {
818 if !enabled {
819 return 0.0;
820 }
821 match variant {
822 SplitButtonVariant::Elevated => {
823 if pressed {
824 0.0
825 } else if hovered {
826 3.0
827 } else {
828 1.0
829 }
830 }
831 SplitButtonVariant::Filled | SplitButtonVariant::Tonal => {
832 if !pressed && hovered {
833 1.0
834 } else {
835 0.0
836 }
837 }
838 SplitButtonVariant::Outlined => 0.0,
839 }
840}
841
842fn resolve_shadow(theme: Option<&Theme>, dp: f64) -> Option<(f64, f64, Color)> {
847 if dp <= 0.0 {
848 return None;
849 }
850 match theme {
851 Some(theme) => {
852 let level = if dp >= theme.elevation.level2.dp {
853 theme.elevation.level2
854 } else {
855 theme.elevation.level1
856 };
857 let shadow = level.shadow(theme.brightness);
858 Some((
859 shadow.blur_std_dev,
860 shadow.y_offset,
861 with_alpha(theme.scheme().shadow, shadow.color_alpha),
862 ))
863 }
864 None => Some((dp, dp / 2.0 + 1.0, FALLBACK_SHADOW_COLOR)),
865 }
866}
867
868fn lerp(from: f64, to: f64, t: f64) -> f64 {
873 from + (to - from) * t
874}
875
876fn corners_match(a: CornerRadii, b: CornerRadii) -> bool {
878 (a.top_left - b.top_left).abs() <= RETARGET_TOLERANCE
879 && (a.top_right - b.top_right).abs() <= RETARGET_TOLERANCE
880 && (a.bottom_right - b.bottom_right).abs() <= RETARGET_TOLERANCE
881 && (a.bottom_left - b.bottom_left).abs() <= RETARGET_TOLERANCE
882}
883
884fn lerp_corners(from: CornerRadii, to: CornerRadii, t: f64) -> CornerRadii {
887 CornerRadii::new(
888 lerp(from.top_left, to.top_left, t).max(0.0),
889 lerp(from.top_right, to.top_right, t).max(0.0),
890 lerp(from.bottom_right, to.bottom_right, t).max(0.0),
891 lerp(from.bottom_left, to.bottom_left, t).max(0.0),
892 )
893}
894
895#[derive(Clone, Copy, Debug)]
904struct ShapeMotion {
905 from_radii: CornerRadii,
906 to_radii: CornerRadii,
907 anim: AnimationController,
908 seeded: bool,
911}
912
913impl ShapeMotion {
914 fn new() -> Self {
917 Self {
918 from_radii: CornerRadii::default(),
919 to_radii: CornerRadii::default(),
920 anim: AnimationController::new(SHAPE_ANIM_PERIOD),
921 seeded: false,
922 }
923 }
924
925 fn retarget(&mut self, radii: CornerRadii) -> bool {
927 if !self.seeded {
928 self.snap_to(radii);
929 return false;
930 }
931 if corners_match(self.to_radii, radii) {
932 return false;
933 }
934 self.from_radii = self.radii();
935 self.to_radii = radii;
936 self.anim = AnimationController::new(SHAPE_ANIM_PERIOD);
937 self.anim.fling(FLING_VELOCITY, SPLIT_SHAPE_SPRING);
938 true
939 }
940
941 fn snap_to(&mut self, radii: CornerRadii) {
943 self.from_radii = radii;
944 self.to_radii = radii;
945 self.anim = AnimationController::new(SHAPE_ANIM_PERIOD);
946 self.seeded = true;
947 }
948
949 fn advance(&mut self, now: FrameTime) -> bool {
952 self.anim.advance(now)
953 }
954
955 fn is_seeded(&self) -> bool {
957 self.seeded
958 }
959
960 fn factor(&self) -> f64 {
962 let raw = self.anim.value();
963 if raw.is_finite() {
964 raw.clamp(0.0, OVERSHOOT_LIMIT)
965 } else {
966 0.0
967 }
968 }
969
970 fn radii(&self) -> CornerRadii {
972 lerp_corners(self.from_radii, self.to_radii, self.factor())
973 }
974
975 #[cfg(test)]
977 fn target_radii(&self) -> CornerRadii {
978 self.to_radii
979 }
980}
981
982fn inside(pos: Point, size: Size) -> bool {
986 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
987}
988
989fn leading_radii(outer: f64, inner: f64) -> CornerRadii {
997 CornerRadii::new(outer, inner, inner, outer)
998}
999
1000fn trailing_radii(outer: f64, inner: f64) -> CornerRadii {
1004 CornerRadii::new(inner, outer, outer, inner)
1005}
1006
1007fn inset_stroke_rrect(size: Size, radii: CornerRadii, width: f64) -> RoundedRect {
1011 let half = width / 2.0;
1012 RoundedRect::from_rect(
1013 Rect::new(half, half, size.width - half, size.height - half),
1014 RoundedRectRadii::new(
1015 (radii.top_left - half).max(0.0),
1016 (radii.top_right - half).max(0.0),
1017 (radii.bottom_right - half).max(0.0),
1018 (radii.bottom_left - half).max(0.0),
1019 ),
1020 )
1021}
1022
1023fn focus_ring_rrect(size: Size, radii: CornerRadii) -> RoundedRect {
1028 let out = FOCUS_RING_GAP + FOCUS_RING_WIDTH / 2.0;
1029 RoundedRect::from_rect(
1030 Rect::new(-out, -out, size.width + out, size.height + out),
1031 RoundedRectRadii::new(
1032 radii.top_left + out,
1033 radii.top_right + out,
1034 radii.bottom_right + out,
1035 radii.bottom_left + out,
1036 ),
1037 )
1038}
1039
1040struct LabelRun {
1046 content: String,
1047 layout: Option<TextLayout>,
1048 shaped_for: Option<(TextStyle, Option<f64>)>,
1049 natural: Option<(TextStyle, f64)>,
1050}
1051
1052impl LabelRun {
1053 fn new(content: impl Into<String>) -> Self {
1054 Self {
1055 content: content.into(),
1056 layout: None,
1057 shaped_for: None,
1058 natural: None,
1059 }
1060 }
1061
1062 fn set_content(&mut self, content: &str) {
1063 if self.content != content {
1064 self.content = content.to_string();
1065 self.layout = None;
1066 self.shaped_for = None;
1067 self.natural = None;
1068 }
1069 }
1070
1071 fn content(&self) -> &str {
1072 &self.content
1073 }
1074
1075 fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
1076 if let Some((cached_style, width)) = &self.natural
1077 && cached_style == style
1078 {
1079 return *width;
1080 }
1081 let text_ctx = ctx.text_context::<TextContext>();
1082 let width = text_ctx.layout(&self.content, style, None).size().width;
1083 self.natural = Some((style.clone(), width));
1084 width
1085 }
1086
1087 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f64>) -> Size {
1088 let key = (style.clone(), max_width);
1089 if let Some(cached) = &self.layout
1090 && self.shaped_for.as_ref() == Some(&key)
1091 {
1092 return cached.size();
1093 }
1094 let text_ctx = ctx.text_context::<TextContext>();
1095 let laid = match max_width {
1096 Some(width) => text_ctx.layout_bounded(
1097 &self.content,
1098 style,
1099 Some(width as f32),
1100 Some(1),
1101 TextOverflow::Ellipsis,
1102 ),
1103 None => text_ctx.layout(&self.content, style, None),
1104 };
1105 let size = laid.size();
1106 self.layout = Some(laid);
1107 self.shaped_for = Some(key);
1108 size
1109 }
1110
1111 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
1112 let Some(layout) = &self.layout else {
1113 return;
1114 };
1115 for mut run in layout.to_scene_runs(origin) {
1116 run.brush = Brush::Solid(color);
1117 scene.draw_glyph_run(run);
1118 }
1119 }
1120}
1121
1122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1126enum SegmentKind {
1127 Leading,
1129 Trailing,
1131}
1132
1133struct SegmentView<State: 'static> {
1136 kind: SegmentKind,
1137 size: SplitButtonSize,
1138 shape: SplitButtonShape,
1139 variant: SplitButtonVariant,
1140 enabled: bool,
1141 open: bool,
1142 trailing_alignment: SplitButtonTrailingAlignment,
1143 label: String,
1144 icon: Option<AnyView<State>>,
1145 haptic: HapticSignal,
1146 anchor: Option<OverlayAnchor>,
1150 on_press: Rc<dyn Fn(&mut State)>,
1151}
1152
1153struct SegmentWidget {
1155 kind: SegmentKind,
1156 size: SplitButtonSize,
1157 shape: SplitButtonShape,
1158 variant: SplitButtonVariant,
1159 enabled: bool,
1160 open: bool,
1161 trailing_alignment: SplitButtonTrailingAlignment,
1162 label: LabelRun,
1163 label_origin: Point,
1164 icon: Option<ChildPod>,
1165 haptic: HapticSignal,
1166 anchor: Option<OverlayAnchor>,
1167 state: InteractionState,
1170 captured: bool,
1172 motion: ShapeMotion,
1174 chevron: Option<(BezPath, f64)>,
1176 chevron_anim: AnimationController,
1178 on_press: ErasedCallback,
1179}
1180
1181impl SegmentWidget {
1182 fn circle_trailing(&self) -> bool {
1192 self.kind == SegmentKind::Trailing
1193 && self.open
1194 && self.shape == SplitButtonShape::Round
1195 && self.size.allows_circle_trailing()
1196 }
1197
1198 fn target_radii(&self, theme: Option<&Theme>, height: f64) -> CornerRadii {
1202 let outer = match self.shape {
1203 SplitButtonShape::Round => height / 2.0,
1204 SplitButtonShape::Square => self.size.square_outer_radius(),
1205 };
1206 let selected = height * (TRAILING_SELECTED_CORNER_PERCENT / 100.0);
1207 let pressed = self.enabled && self.state.pressed;
1208 let hovered = self.enabled && self.state.hovered && !(self.open && self.is_trailing());
1211
1212 match self.kind {
1213 SegmentKind::Leading => {
1214 let inner = if pressed {
1215 self.size.pressed_radius()
1216 } else if hovered {
1217 self.size.hovered_inner_radius()
1218 } else {
1219 self.size.inner_radius(theme)
1220 };
1221 leading_radii(outer, inner)
1222 }
1223 SegmentKind::Trailing => {
1224 if self.circle_trailing() {
1225 return CornerRadii::uniform(selected);
1226 }
1227 let outer = if self.open { selected } else { outer };
1228 let inner = if self.open {
1229 selected
1230 } else if pressed {
1231 self.size.pressed_radius()
1232 } else if hovered {
1233 self.size.hovered_inner_radius()
1234 } else {
1235 self.size.inner_radius(theme)
1236 };
1237 trailing_radii(outer, inner)
1238 }
1239 }
1240 }
1241
1242 fn is_trailing(&self) -> bool {
1243 self.kind == SegmentKind::Trailing
1244 }
1245
1246 fn chevron_path(&self, size: Size) -> Option<BezPath> {
1250 let (path, design) = self.chevron.as_ref()?;
1251 if *design <= 0.0 {
1252 return None;
1253 }
1254 let dx = match self.trailing_alignment {
1255 SplitButtonTrailingAlignment::OpticalCenter
1256 if !self.circle_trailing() && !self.open =>
1257 {
1258 self.size.menu_icon_offset()
1259 }
1260 _ => 0.0,
1261 };
1262 let center = Point::new(size.width / 2.0 + dx, size.height / 2.0);
1263 let angle = self.chevron_anim.value() * CHEVRON_OPEN_TURNS * std::f64::consts::TAU;
1264 let scale = self.size.trailing_icon_size() / design;
1265 let transform = Affine::translate(Vec2::new(center.x, center.y))
1266 * Affine::rotate(angle)
1267 * Affine::scale(scale)
1268 * Affine::translate(Vec2::new(-design / 2.0, -design / 2.0));
1269 Some(transform * path.clone())
1270 }
1271
1272 fn activates(key: &Key) -> bool {
1276 match key {
1277 Key::Named(NamedKey::Enter) => true,
1278 Key::Character(text) => text == " ",
1279 Key::Named(_) => false,
1280 }
1281 }
1282
1283 fn activate(&mut self, ctx: &mut EventCtx) {
1286 if self.haptic != HapticSignal::None {
1287 MaterialHaptics::fire(self.haptic);
1288 }
1289 (self.on_press)(ctx);
1290 }
1291}
1292
1293impl<State: 'static> View<State> for SegmentView<State> {
1294 type Element = SegmentWidget;
1295
1296 fn build(&self, ctx: &mut BuildCtx<'_>) -> SegmentWidget {
1297 let mut chevron_anim =
1298 AnimationController::new(CHEVRON_ROTATION_DURATION).with_curve(CHEVRON_ROTATION_CURVE);
1299 if self.kind == SegmentKind::Trailing && self.open {
1300 chevron_anim.animate_to(1.0);
1303 chevron_anim.stop();
1304 }
1305 SegmentWidget {
1306 kind: self.kind,
1307 size: self.size,
1308 shape: self.shape,
1309 variant: self.variant,
1310 enabled: self.enabled,
1311 open: self.open,
1312 trailing_alignment: self.trailing_alignment,
1313 label: LabelRun::new(self.label.clone()),
1319 label_origin: Point::ZERO,
1320 icon: self.icon.as_ref().map(|icon| build_child(icon, ctx)),
1321 haptic: self.haptic,
1322 anchor: self.anchor.clone(),
1323 state: InteractionState::new(),
1324 captured: false,
1325 motion: ShapeMotion::new(),
1326 chevron: (self.kind == SegmentKind::Trailing)
1327 .then(|| IconData::from(icons::KEYBOARD_ARROW_DOWN).resolve()),
1328 chevron_anim,
1329 on_press: erase_callback(&self.on_press),
1330 }
1331 }
1332
1333 fn rebuild(
1334 &self,
1335 prev: &Self,
1336 element: &mut SegmentWidget,
1337 ctx: &mut BuildCtx<'_>,
1338 ) -> ChangeFlags {
1339 element.on_press = erase_callback(&self.on_press);
1340 element.haptic = self.haptic;
1341 element.anchor = self.anchor.clone();
1342 let mut flags = ChangeFlags::NONE;
1343
1344 if prev.size != self.size {
1345 element.size = self.size;
1346 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1347 }
1348 if prev.shape != self.shape {
1349 element.shape = self.shape;
1350 flags |= ChangeFlags::PAINT;
1351 }
1352 if prev.variant != self.variant {
1353 element.variant = self.variant;
1354 flags |= ChangeFlags::PAINT;
1355 }
1356 if prev.trailing_alignment != self.trailing_alignment {
1357 element.trailing_alignment = self.trailing_alignment;
1358 flags |= ChangeFlags::PAINT;
1359 }
1360 if prev.enabled != self.enabled {
1361 element.enabled = self.enabled;
1362 if !self.enabled {
1363 element.state.set_pressed(false);
1366 element.captured = false;
1367 }
1368 flags |= ChangeFlags::PAINT;
1369 }
1370 if prev.open != self.open {
1371 element.open = self.open;
1372 if element.kind == SegmentKind::Trailing {
1373 element
1374 .chevron_anim
1375 .animate_to(if self.open { 1.0 } else { 0.0 });
1376 }
1377 flags |= ChangeFlags::PAINT;
1378 }
1379 if prev.label != self.label {
1380 element.label.set_content(&self.label);
1381 flags |= if self.kind == SegmentKind::Leading {
1382 ChangeFlags::LAYOUT | ChangeFlags::PAINT
1384 } else {
1385 ChangeFlags::PAINT
1389 };
1390 }
1391
1392 match (prev.icon.as_ref(), self.icon.as_ref()) {
1393 (None, None) => {}
1394 (Some(p), Some(n)) => {
1395 let pod = element.icon.as_mut().expect("icon pod present");
1396 flags |= rebuild_child(p, n, pod, ctx);
1397 }
1398 (None, Some(n)) => {
1399 element.icon = Some(build_child(n, ctx));
1400 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1401 }
1402 (Some(p), None) => {
1403 let mut pod = element.icon.take().expect("icon pod present");
1404 teardown_child(p, &mut pod, ctx);
1405 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1406 }
1407 }
1408
1409 flags
1410 }
1411
1412 fn teardown(&self, element: &mut SegmentWidget, ctx: &mut BuildCtx<'_>) {
1413 if let (Some(icon), Some(pod)) = (self.icon.as_ref(), element.icon.as_mut()) {
1414 teardown_child(icon, pod, ctx);
1415 }
1416 }
1417}
1418
1419impl Widget for SegmentWidget {
1420 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1421 let height = self.size.height();
1422 if !self.motion.is_seeded() {
1423 let theme = Theme::from_layout_ctx(ctx);
1424 self.motion.snap_to(self.target_radii(theme, height));
1425 }
1426
1427 if self.kind == SegmentKind::Trailing {
1428 let width = self
1429 .size
1430 .trailing_width_when(self.open, self.circle_trailing());
1431 return bc.constrain(Size::new(width, height));
1432 }
1433
1434 let left = self.size.left_outer_padding();
1435 let right = self.size.label_right_padding();
1436 let icon_size = self.icon.as_mut().map(|pod| {
1437 let side = self.size.icon_size();
1438 pod.layout_child(ctx, &BoxConstraints::tight(Size::new(side, side)))
1439 });
1440 let icon_width = icon_size.map_or(0.0, |s| s.width);
1441 let has_label = !self.label.content().is_empty();
1442 let gap = if icon_size.is_some() && has_label {
1443 self.size.icon_to_label_gap()
1444 } else {
1445 0.0
1446 };
1447
1448 let style = self.size.label_style(Theme::from_layout_ctx(ctx));
1449 let available = if bc.max().width.is_finite() {
1450 (bc.max().width - left - right - icon_width - gap).max(0.0)
1451 } else {
1452 f64::INFINITY
1453 };
1454 let natural = self.label.natural_width(ctx, &style);
1455 let fit_to = (natural > available)
1456 .then_some(available)
1457 .filter(|w| w.is_finite());
1458 let label_size = self.label.shape(ctx, &style, fit_to);
1459
1460 let width = left + icon_width + gap + label_size.width + right;
1461 let size = bc.constrain(Size::new(width, height));
1462
1463 if let Some(pod) = self.icon.as_mut() {
1464 let icon_height = icon_size.map_or(0.0, |s| s.height);
1465 pod.set_origin(Point::new(left, (size.height - icon_height) / 2.0));
1466 }
1467 self.label_origin = Point::new(
1468 left + icon_width + gap,
1469 (size.height - label_size.height) / 2.0,
1470 );
1471 size
1472 }
1473
1474 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1475 let origin = ctx.origin();
1476 let size = ctx.size();
1477
1478 if let Some(anchor) = &self.anchor {
1481 anchor.set(Rect::from_origin_size(origin, size));
1482 }
1483
1484 self.state.set_hovered(self.enabled && ctx.is_hovered());
1488 self.state.set_focused(self.enabled && ctx.has_focus());
1489
1490 let (colors, shadow, radii_target) = {
1491 let theme = Theme::from_paint_ctx(ctx);
1492 let dp = elevation_dp(
1493 self.variant,
1494 self.enabled,
1495 self.state.pressed,
1496 self.state.hovered,
1497 );
1498 (
1499 resolve_colors(theme, self.variant, self.enabled),
1500 resolve_shadow(theme, dp),
1501 self.target_radii(theme, size.height),
1502 )
1503 };
1504
1505 if self.motion.retarget(radii_target) {
1506 ctx.request_frame();
1507 }
1508 if self.motion.advance(ctx.frame_time()) {
1509 ctx.request_frame();
1510 }
1511 if self.chevron_anim.advance(ctx.frame_time()) {
1512 ctx.request_frame();
1513 }
1514 let radii = self.motion.radii();
1515
1516 if let Some((blur, y_offset, shadow_color)) = shadow {
1517 scene.draw_shadow(
1518 Point::new(origin.x, origin.y + y_offset),
1519 size,
1520 radii.largest(),
1521 blur,
1522 shadow_color,
1523 );
1524 }
1525 if colors.container.components[3] > 0.0 {
1526 scene.fill_rounded_rect_radii(origin, size, radii, colors.container);
1527 }
1528
1529 scene.push_clip_rounded_radii(origin, size, radii);
1534 if self.enabled {
1535 let opacity = self.state.resolve_opacity();
1536 if opacity > 0.0 {
1537 scene.fill_rounded_rect_radii(
1538 origin,
1539 size,
1540 radii,
1541 with_alpha(colors.content, opacity),
1542 );
1543 }
1544 }
1545 match self.kind {
1546 SegmentKind::Leading => {
1547 if let Some(pod) = self.icon.as_mut() {
1548 pod.paint_child(ctx, scene);
1549 }
1550 self.label.paint(
1551 Point::new(
1552 origin.x + self.label_origin.x,
1553 origin.y + self.label_origin.y,
1554 ),
1555 colors.content,
1556 scene,
1557 );
1558 }
1559 SegmentKind::Trailing => {
1560 if let Some(path) = self.chevron_path(size) {
1561 scene.fill_path(origin, &path, &Brush::Solid(colors.content));
1562 }
1563 }
1564 }
1565 scene.pop_clip();
1566
1567 if let Some(outline) = colors.outline {
1568 let path = inset_stroke_rrect(size, radii, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
1569 scene.stroke_path(origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
1570 }
1571 if self.state.focused {
1572 let ring = focus_ring_rrect(size, radii).to_path(PATH_TOLERANCE);
1573 scene.stroke_path(
1574 origin,
1575 &ring,
1576 FOCUS_RING_WIDTH,
1577 &Brush::Solid(colors.focus_ring),
1578 );
1579 }
1580 }
1581
1582 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1583 if !self.enabled {
1584 return EventResult::Ignored;
1587 }
1588 match event {
1589 InputEvent::Key(key) => {
1590 if key.repeat || !Self::activates(&key.key) {
1591 return EventResult::Ignored;
1592 }
1593 self.activate(ctx);
1594 ctx.request_redraw();
1595 EventResult::Handled
1596 }
1597 InputEvent::Pointer(p) => match p.phase {
1598 PointerPhase::Down => {
1599 if !presses(p) {
1600 return EventResult::Ignored;
1601 }
1602 self.state.set_pressed(true);
1603 self.captured = true;
1604 ctx.capture_pointer();
1605 ctx.request_redraw();
1606 EventResult::Handled
1607 }
1608 PointerPhase::Move => {
1609 let over = inside(p.position, ctx.size());
1610 if !self.captured {
1611 if over {
1612 ctx.claim_hover();
1613 ctx.set_cursor(CursorIcon::Pointer);
1614 }
1615 if self.state.set_hovered(over) {
1616 ctx.request_redraw();
1617 }
1618 return EventResult::Ignored;
1619 }
1620 ctx.set_cursor(CursorIcon::Pointer);
1621 if self.state.set_pressed(over) {
1622 ctx.request_redraw();
1623 }
1624 EventResult::Handled
1625 }
1626 PointerPhase::Up => {
1627 if !self.captured {
1628 return EventResult::Ignored;
1629 }
1630 if inside(p.position, ctx.size()) {
1631 self.activate(ctx);
1632 }
1633 self.state.set_pressed(false);
1634 self.captured = false;
1635 ctx.request_redraw();
1636 EventResult::Handled
1637 }
1638 PointerPhase::Cancel => {
1639 if !self.captured {
1640 return EventResult::Ignored;
1641 }
1642 self.state.set_pressed(false);
1643 self.captured = false;
1644 ctx.request_redraw();
1645 EventResult::Handled
1646 }
1647 },
1648 _ => EventResult::Ignored,
1649 }
1650 }
1651
1652 fn semantics(&self, ctx: &mut SemanticsCtx) {
1653 ctx.push_node(Role::Button, |node| {
1659 if self.is_trailing() {
1660 node.set_label(if self.label.content().is_empty() {
1665 DEFAULT_TRAILING_LABEL
1666 } else {
1667 self.label.content()
1668 });
1669 node.set_expanded(self.open);
1670 } else if !self.label.content().is_empty() {
1671 node.set_label(self.label.content());
1672 }
1673 if self.enabled {
1674 node.add_action(Action::Click);
1675 if self.is_trailing() {
1676 node.add_action(Action::Expand);
1677 }
1678 } else {
1679 node.set_disabled();
1680 }
1681 });
1682 }
1683
1684 visit_children!(icon);
1685}
1686
1687fn build_segment<State: 'static>(view: &SegmentView<State>, ctx: &mut BuildCtx<'_>) -> ChildPod {
1694 ChildPod::new(Box::new(View::<State>::build(view, ctx)))
1695}
1696
1697fn segment_of(pod: &mut ChildPod) -> &mut SegmentWidget {
1699 pod.widget_mut()
1700 .downcast_mut::<SegmentWidget>()
1701 .expect("a split button pod holds a SegmentWidget")
1702}
1703
1704type Callback<State> = Rc<dyn Fn(&mut State)>;
1706type OnSelect<State> = Rc<dyn Fn(&mut State, MenuSelection)>;
1708type IconFactory<State> = Rc<dyn Fn() -> AnyView<State>>;
1711
1712pub struct SplitButtonView<State: 'static> {
1714 label: String,
1715 trailing_label: Option<String>,
1716 size: SplitButtonSize,
1717 shape: SplitButtonShape,
1718 variant: SplitButtonVariant,
1719 trailing_alignment: SplitButtonTrailingAlignment,
1720 enabled: bool,
1721 trailing_enabled: bool,
1722 open: bool,
1724 gap: Option<f64>,
1725 haptic: HapticSignal,
1726 leading_icon: Option<IconFactory<State>>,
1727 items: Vec<SplitButtonItem>,
1728 selected_value: Option<String>,
1729 menu_style: SplitButtonMenuStyle,
1730 menu_anchor: OverlayAnchor,
1731 on_press: Callback<State>,
1732 on_open: Callback<State>,
1733 on_select: Option<OnSelect<State>>,
1734}
1735
1736pub fn split_button<State, P, O>(
1751 label: impl Into<String>,
1752 open: bool,
1753 on_press: P,
1754 on_open: O,
1755) -> SplitButtonView<State>
1756where
1757 State: 'static,
1758 P: Fn(&mut State) + 'static,
1759 O: Fn(&mut State) + 'static,
1760{
1761 SplitButtonView {
1762 label: label.into(),
1763 trailing_label: None,
1764 size: SplitButtonSize::default(),
1765 shape: SplitButtonShape::default(),
1766 variant: SplitButtonVariant::default(),
1767 trailing_alignment: SplitButtonTrailingAlignment::default(),
1768 enabled: true,
1769 trailing_enabled: true,
1770 open,
1771 gap: None,
1772 haptic: HapticSignal::None,
1773 leading_icon: None,
1774 items: Vec::new(),
1775 selected_value: None,
1776 menu_style: SplitButtonMenuStyle::default(),
1777 menu_anchor: OverlayAnchor::new(),
1778 on_press: Rc::new(on_press),
1779 on_open: Rc::new(on_open),
1780 on_select: None,
1781 }
1782}
1783
1784#[allow(non_snake_case)]
1787pub fn SplitButton<State, P, O>(
1788 label: impl Into<String>,
1789 open: bool,
1790 on_press: P,
1791 on_open: O,
1792) -> SplitButtonView<State>
1793where
1794 State: 'static,
1795 P: Fn(&mut State) + 'static,
1796 O: Fn(&mut State) + 'static,
1797{
1798 split_button(label, open, on_press, on_open)
1799}
1800
1801impl<State: 'static> SplitButtonView<State> {
1802 pub fn size(mut self, size: SplitButtonSize) -> Self {
1804 self.size = size;
1805 self
1806 }
1807
1808 pub fn shape(mut self, shape: SplitButtonShape) -> Self {
1810 self.shape = shape;
1811 self
1812 }
1813
1814 pub fn variant(mut self, variant: SplitButtonVariant) -> Self {
1816 self.variant = variant;
1817 self
1818 }
1819
1820 pub fn trailing_alignment(mut self, alignment: SplitButtonTrailingAlignment) -> Self {
1822 self.trailing_alignment = alignment;
1823 self
1824 }
1825
1826 pub fn enabled(mut self, enabled: bool) -> Self {
1830 self.enabled = enabled;
1831 self
1832 }
1833
1834 pub fn trailing_enabled(mut self, enabled: bool) -> Self {
1843 self.trailing_enabled = enabled;
1844 self
1845 }
1846
1847 pub fn leading_icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
1851 self.leading_icon = Some(Rc::new(icon));
1852 self
1853 }
1854
1855 pub fn trailing_label(mut self, label: impl Into<String>) -> Self {
1859 self.trailing_label = Some(label.into());
1860 self
1861 }
1862
1863 pub fn gap(mut self, gap: f64) -> Self {
1868 self.gap = Some(gap);
1869 self
1870 }
1871
1872 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
1876 self.haptic = haptic;
1877 self
1878 }
1879
1880 pub fn items(mut self, items: Vec<SplitButtonItem>) -> Self {
1883 self.items = items;
1884 self
1885 }
1886
1887 pub fn selected_value(mut self, value: Option<String>) -> Self {
1890 self.selected_value = value;
1891 self
1892 }
1893
1894 pub fn menu_style(mut self, style: SplitButtonMenuStyle) -> Self {
1896 self.menu_style = style;
1897 self
1898 }
1899
1900 pub fn on_select<F: Fn(&mut State, MenuSelection) + 'static>(mut self, on_select: F) -> Self {
1902 self.on_select = Some(Rc::new(on_select));
1903 self
1904 }
1905
1906 pub fn menu_anchor(mut self, anchor: &OverlayAnchor) -> Self {
1911 self.menu_anchor = anchor.clone();
1912 self
1913 }
1914
1915 fn trailing_is_enabled(&self) -> bool {
1917 self.enabled && self.trailing_enabled
1918 }
1919
1920 fn base_gap(&self) -> f64 {
1923 self.gap
1924 .unwrap_or(if self.variant == SplitButtonVariant::Elevated {
1925 SPLIT_ELEVATED_INNER_GAP
1926 } else {
1927 SPLIT_INNER_GAP
1928 })
1929 }
1930
1931 fn menu_nodes(&self) -> Vec<MenuNode> {
1933 self.items
1934 .iter()
1935 .map(SplitButtonItem::to_menu_node)
1936 .collect()
1937 }
1938
1939 pub fn popup_menu(&self) -> AnyView<State> {
1945 let on_select = self.on_select.clone();
1946 any(
1947 menu(self.menu_nodes(), move |state: &mut State, selection| {
1948 if let Some(callback) = &on_select {
1949 callback(state, selection);
1950 }
1951 })
1952 .anchor(&self.menu_anchor)
1953 .selected(self.selected_value.clone())
1954 .open(self.open),
1955 )
1956 }
1957
1958 pub fn sheet_menu(&self) -> BottomSheetView<State> {
1963 let on_select = self.on_select.clone();
1964 bottom_sheet(
1965 menu_panel(self.menu_nodes(), move |state: &mut State, selection| {
1966 if let Some(callback) = &on_select {
1967 callback(state, selection);
1968 }
1969 })
1970 .selected(self.selected_value.clone()),
1971 )
1972 }
1973
1974 pub fn menu_route(&self) -> SplitButtonMenuRoute<State> {
1978 match self.menu_style {
1979 SplitButtonMenuStyle::Popup => SplitButtonMenuRoute::Popup(self.popup_menu()),
1980 SplitButtonMenuStyle::BottomSheet => SplitButtonMenuRoute::Sheet(self.sheet_menu()),
1981 }
1982 }
1983
1984 fn leading_view(&self) -> SegmentView<State> {
1986 let on_press = self.on_press.clone();
1987 SegmentView {
1988 kind: SegmentKind::Leading,
1989 size: self.size,
1990 shape: self.shape,
1991 variant: self.variant,
1992 enabled: self.enabled,
1993 open: self.open,
1994 trailing_alignment: self.trailing_alignment,
1995 label: self.label.clone(),
1996 icon: self.leading_icon.as_ref().map(|factory| factory()),
1997 haptic: self.haptic,
1998 anchor: None,
1999 on_press,
2000 }
2001 }
2002
2003 fn trailing_view(&self) -> SegmentView<State> {
2005 let on_open = self.on_open.clone();
2006 SegmentView {
2007 kind: SegmentKind::Trailing,
2008 size: self.size,
2009 shape: self.shape,
2010 variant: self.variant,
2011 enabled: self.trailing_is_enabled(),
2012 open: self.open,
2013 trailing_alignment: self.trailing_alignment,
2014 label: self
2015 .trailing_label
2016 .clone()
2017 .unwrap_or_else(|| DEFAULT_TRAILING_LABEL.to_string()),
2018 icon: None,
2019 haptic: self.haptic,
2020 anchor: Some(self.menu_anchor.clone()),
2021 on_press: on_open,
2022 }
2023 }
2024}
2025
2026pub struct SplitButtonWidget {
2030 segments: Vec<ChildPod>,
2032 size: SplitButtonSize,
2033 base_gap: f64,
2034 gap: f64,
2036}
2037
2038impl SplitButtonWidget {
2039 fn resolved_gap(&self) -> f64 {
2043 let focused = self.segments.iter().any(ChildPod::is_focused);
2044 self.base_gap
2045 + if focused {
2046 SPLIT_FOCUS_RING_OUTSET
2047 } else {
2048 0.0
2049 }
2050 }
2051}
2052
2053impl<State: 'static> View<State> for SplitButtonView<State> {
2054 type Element = SplitButtonWidget;
2055
2056 fn build(&self, ctx: &mut BuildCtx<'_>) -> SplitButtonWidget {
2057 SplitButtonWidget {
2058 segments: vec![
2059 build_segment(&self.leading_view(), ctx),
2060 build_segment(&self.trailing_view(), ctx),
2061 ],
2062 size: self.size,
2063 base_gap: self.base_gap(),
2064 gap: self.base_gap(),
2065 }
2066 }
2067
2068 fn rebuild(
2069 &self,
2070 prev: &Self,
2071 element: &mut SplitButtonWidget,
2072 ctx: &mut BuildCtx<'_>,
2073 ) -> ChangeFlags {
2074 let mut flags = ChangeFlags::NONE;
2075 if prev.size != self.size {
2076 element.size = self.size;
2077 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2078 }
2079 let base_gap = self.base_gap();
2080 if element.base_gap != base_gap {
2081 element.base_gap = base_gap;
2082 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2083 }
2084 flags |= View::<State>::rebuild(
2085 &self.leading_view(),
2086 &prev.leading_view(),
2087 segment_of(&mut element.segments[0]),
2088 ctx,
2089 );
2090 flags |= View::<State>::rebuild(
2091 &self.trailing_view(),
2092 &prev.trailing_view(),
2093 segment_of(&mut element.segments[1]),
2094 ctx,
2095 );
2096 flags
2097 }
2098
2099 fn teardown(&self, element: &mut SplitButtonWidget, ctx: &mut BuildCtx<'_>) {
2100 View::<State>::teardown(
2101 &self.leading_view(),
2102 segment_of(&mut element.segments[0]),
2103 ctx,
2104 );
2105 View::<State>::teardown(
2106 &self.trailing_view(),
2107 segment_of(&mut element.segments[1]),
2108 ctx,
2109 );
2110 }
2111}
2112
2113impl Widget for SplitButtonWidget {
2114 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2115 let gap = self.resolved_gap();
2116 self.gap = gap;
2117 let segment_height = self.size.height();
2118 let height = segment_height.max(SPLIT_MIN_TAP_TARGET);
2121
2122 let trailing = self.segments[1].layout_child(
2125 ctx,
2126 &BoxConstraints::loose(Size::new(bc.max().width, segment_height)),
2127 );
2128 let leading_max = if bc.max().width.is_finite() {
2129 (bc.max().width - gap - trailing.width).max(0.0)
2130 } else {
2131 f64::INFINITY
2132 };
2133 let leading = self.segments[0].layout_child(
2134 ctx,
2135 &BoxConstraints::loose(Size::new(leading_max, segment_height)),
2136 );
2137
2138 self.segments[0].set_origin(Point::new(0.0, (height - leading.height) / 2.0));
2139 self.segments[1].set_origin(Point::new(
2140 leading.width + gap,
2141 (height - trailing.height) / 2.0,
2142 ));
2143 bc.constrain(Size::new(leading.width + gap + trailing.width, height))
2144 }
2145
2146 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2147 for pod in &mut self.segments {
2148 pod.paint_child(ctx, scene);
2149 }
2150 }
2151
2152 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2153 route_event(&mut self.segments, ctx, event)
2158 }
2159
2160 fn semantics(&self, ctx: &mut SemanticsCtx) {
2161 ctx.push_container(
2165 Role::Group,
2166 |_| {},
2167 |ctx| {
2168 for pod in &self.segments {
2169 pod.semantics_child(ctx);
2170 }
2171 },
2172 );
2173 }
2174
2175 visit_children!(segments);
2176}
2177
2178#[cfg(test)]
2179mod tests {
2180 use super::*;
2181 use frust::Brightness;
2182 use frust::authoring::scene::GlyphRun;
2183 use frust::authoring::{KeyEvent, Modifiers, PointerButton, PointerEvent};
2184 use std::any::Any;
2185
2186 #[derive(Default)]
2189 struct Log {
2190 presses: u32,
2191 opens: u32,
2192 selections: Vec<MenuSelection>,
2193 }
2194
2195 fn ft_secs(s: f64) -> FrameTime {
2196 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
2197 }
2198
2199 fn build<S: 'static>(view: &SplitButtonView<S>) -> SplitButtonWidget {
2200 let mut counter = 0u64;
2201 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
2202 }
2203
2204 fn rebuild<S: 'static>(
2205 prev: &SplitButtonView<S>,
2206 next: &SplitButtonView<S>,
2207 widget: &mut SplitButtonWidget,
2208 ) {
2209 let mut counter = 0u64;
2210 View::<S>::rebuild(next, prev, widget, &mut BuildCtx::new(&mut counter));
2211 }
2212
2213 fn plain(open: bool) -> SplitButtonView<Log> {
2214 split_button::<Log, _, _>(
2215 "Save",
2216 open,
2217 |s: &mut Log| s.presses += 1,
2218 |s: &mut Log| s.opens += 1,
2219 )
2220 }
2221
2222 fn layout_with(widget: &mut SplitButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
2223 let mut tcx = TextContext::new();
2224 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2225 if let Some(theme) = theme {
2226 lctx = lctx.with_theme(theme as &dyn Any);
2227 }
2228 widget.layout(
2229 &mut lctx,
2230 &BoxConstraints::loose(Size::new(max_width, 500.0)),
2231 )
2232 }
2233
2234 fn laid_out(view: &SplitButtonView<Log>) -> SplitButtonWidget {
2235 let mut widget = build(view);
2236 layout_with(&mut widget, 600.0, None);
2237 widget
2238 }
2239
2240 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
2241 InputEvent::Pointer(PointerEvent {
2242 phase,
2243 position: Point::new(x, y),
2244 button: PointerButton::Primary,
2245 })
2246 }
2247
2248 fn key(named: NamedKey) -> InputEvent {
2249 InputEvent::Key(KeyEvent {
2250 key: Key::Named(named),
2251 modifiers: Modifiers::default(),
2252 repeat: false,
2253 })
2254 }
2255
2256 fn dispatch(widget: &mut SplitButtonWidget, state: &mut Log, size: Size, event: &InputEvent) {
2257 let state_any: &mut dyn Any = state;
2258 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2259 widget.event(&mut ctx, event);
2260 }
2261
2262 fn click(widget: &mut SplitButtonWidget, state: &mut Log, size: Size, x: f64, y: f64) {
2264 dispatch(widget, state, size, &ev(PointerPhase::Down, x, y));
2265 dispatch(widget, state, size, &ev(PointerPhase::Up, x, y));
2266 }
2267
2268 fn segment(widget: &mut SplitButtonWidget, index: usize) -> &mut SegmentWidget {
2269 segment_of(&mut widget.segments[index])
2270 }
2271
2272 #[derive(Default)]
2273 struct Recorder {
2274 rrects: Vec<(Point, Size, CornerRadii, Color)>,
2275 clips: Vec<(Point, Size, CornerRadii)>,
2276 pops: usize,
2277 paths: Vec<Color>,
2278 strokes: Vec<(f64, Color)>,
2279 runs: Vec<(Point, Color)>,
2280 shadows: usize,
2281 }
2282
2283 fn solid(brush: &Brush) -> Color {
2284 match brush {
2285 Brush::Solid(c) => *c,
2286 _ => Color::TRANSPARENT,
2287 }
2288 }
2289
2290 impl PaintScene for Recorder {
2291 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
2292 fn draw_text(&mut self, _o: Point, _t: &str) {}
2293 fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
2294 self.rrects.push((o, s, radii, color));
2295 }
2296 fn push_clip_rounded_radii(&mut self, o: Point, s: Size, radii: CornerRadii) {
2297 self.clips.push((o, s, radii));
2298 }
2299 fn pop_clip(&mut self) {
2300 self.pops += 1;
2301 }
2302 fn fill_path(&mut self, _o: Point, _p: &BezPath, brush: &Brush) {
2303 self.paths.push(solid(brush));
2304 }
2305 fn stroke_path(&mut self, _o: Point, _p: &BezPath, width: f64, brush: &Brush) {
2306 self.strokes.push((width, solid(brush)));
2307 }
2308 fn draw_glyph_run(&mut self, run: GlyphRun) {
2309 let t = run.transform.translation();
2310 self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
2311 }
2312 fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _sd: f64, _c: Color) {
2313 self.shadows += 1;
2314 }
2315 }
2316
2317 fn paint_at(widget: &mut SplitButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
2318 let mut rec = Recorder::default();
2319 let mut pctx = PaintCtx::new(Point::ZERO, size);
2320 if let Some(theme) = theme {
2321 pctx = pctx.with_theme(theme as &dyn Any);
2322 }
2323 widget.paint(&mut pctx, &mut rec);
2324 rec
2325 }
2326
2327 fn settle_morph(segment: &mut SegmentWidget) {
2329 let mut t = 0.0;
2330 for _ in 0..600 {
2331 t += 1.0 / 60.0;
2332 if !segment.motion.advance(ft_secs(t)) {
2333 return;
2334 }
2335 }
2336 panic!("the shape morph never settled");
2337 }
2338
2339 #[test]
2342 fn every_size_row_matches_the_reference_theme_tables() {
2343 let table = [
2347 (SplitButtonSize::Xs, 32.0, 22.0, 12.0, 10.0, 20.0),
2348 (SplitButtonSize::Sm, 40.0, 22.0, 16.0, 12.0, 24.0),
2349 (SplitButtonSize::Md, 56.0, 26.0, 24.0, 24.0, 24.0),
2350 (SplitButtonSize::Lg, 96.0, 38.0, 48.0, 48.0, 32.0),
2351 (SplitButtonSize::Xl, 136.0, 50.0, 64.0, 64.0, 40.0),
2352 ];
2353 for (size, height, chevron, left, right, icon) in table {
2354 assert_eq!(size.height(), height, "{size:?} height");
2355 assert_eq!(size.trailing_icon_size(), chevron, "{size:?} chevron");
2356 assert_eq!(size.left_outer_padding(), left, "{size:?} left pad");
2357 assert_eq!(size.label_right_padding(), right, "{size:?} right pad");
2358 assert_eq!(size.icon_size(), icon, "{size:?} icon");
2359 }
2360 assert_eq!(SplitButtonSize::default(), SplitButtonSize::Sm);
2361 assert_eq!(SplitButtonSize::ALL.len(), 5);
2362 }
2363
2364 #[test]
2365 fn the_three_trailing_width_formulas_agree() {
2366 for size in SplitButtonSize::ALL {
2371 assert_eq!(
2372 size.trailing_width_unselected(),
2373 size.trailing_width_selected(),
2374 "{size:?}"
2375 );
2376 if size.allows_circle_trailing() {
2377 assert_eq!(
2378 size.trailing_width_unselected(),
2379 size.height(),
2380 "{size:?} circle width is the segment height"
2381 );
2382 }
2383 assert_eq!(
2384 size.trailing_width(),
2385 size.trailing_width_unselected().max(SPLIT_MIN_TAP_TARGET)
2386 );
2387 }
2388 assert_eq!(SplitButtonSize::Xs.trailing_width(), 48.0);
2389 assert_eq!(SplitButtonSize::Md.trailing_width(), 56.0);
2390 assert_eq!(SplitButtonSize::Xl.trailing_width(), 136.0);
2391 }
2392
2393 #[test]
2394 fn each_size_lays_its_segments_out_at_the_table_height_and_trailing_width() {
2395 for size in SplitButtonSize::ALL {
2396 let mut widget = laid_out(&plain(false).size(size));
2397 let expected_height = size.height().max(SPLIT_MIN_TAP_TARGET);
2398 let outer = layout_with(&mut widget, 600.0, None);
2399 assert_eq!(outer.height, expected_height, "{size:?} outer height");
2400 assert_eq!(
2401 widget.segments[0].size().height,
2402 size.height(),
2403 "{size:?} leading segment height"
2404 );
2405 assert_eq!(
2406 widget.segments[1].size().width,
2407 size.trailing_width(),
2408 "{size:?} trailing segment width"
2409 );
2410 let leading_right = widget.segments[0].size().width;
2412 assert_eq!(
2413 widget.segments[1].origin().x,
2414 leading_right + SPLIT_INNER_GAP,
2415 "{size:?} gap"
2416 );
2417 }
2418 }
2419
2420 #[test]
2421 fn each_variant_resolves_its_own_container_and_content_roles() {
2422 let theme = crate::baseline().with_brightness(Brightness::Light);
2425 let s = theme.scheme();
2426 let expected = [
2427 (SplitButtonVariant::Filled, s.primary, s.on_primary, false),
2428 (
2429 SplitButtonVariant::Tonal,
2430 s.secondary_container,
2431 s.on_secondary_container,
2432 false,
2433 ),
2434 (
2435 SplitButtonVariant::Elevated,
2436 s.surface_container_low,
2437 s.primary,
2438 false,
2439 ),
2440 (
2441 SplitButtonVariant::Outlined,
2442 Color::TRANSPARENT,
2443 s.primary,
2444 true,
2445 ),
2446 ];
2447 for (variant, container, content, outlined) in expected {
2448 let colors = resolve_colors(Some(&theme), variant, true);
2449 assert_eq!(colors.container, container, "{variant:?} container");
2450 assert_eq!(colors.content, content, "{variant:?} content");
2451 assert_eq!(colors.outline.is_some(), outlined, "{variant:?} outline");
2452 assert_eq!(colors.focus_ring, s.primary, "{variant:?} focus ring");
2453 }
2454 assert_eq!(SplitButtonVariant::default(), SplitButtonVariant::Filled);
2455 assert_eq!(SplitButtonVariant::ALL.len(), 4);
2456 }
2457
2458 #[test]
2459 fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
2460 let light = crate::baseline().with_brightness(Brightness::Light);
2461 for variant in SplitButtonVariant::ALL {
2462 for enabled in [true, false] {
2463 assert_eq!(
2464 resolve_colors(None, variant, enabled),
2465 resolve_colors(Some(&light), variant, enabled),
2466 "{variant:?} enabled={enabled}"
2467 );
2468 }
2469 }
2470 }
2471
2472 #[test]
2473 fn a_disabled_segment_takes_the_reference_disabled_alphas() {
2474 let theme = crate::baseline().with_brightness(Brightness::Light);
2477 let on_surface = theme.scheme().on_surface;
2478 let filled = resolve_colors(Some(&theme), SplitButtonVariant::Filled, false);
2479 assert_eq!(
2480 filled.content,
2481 with_alpha(on_surface, DISABLED_CONTENT_OPACITY)
2482 );
2483 assert_eq!(
2484 filled.container,
2485 with_alpha(on_surface, DISABLED_CONTAINER_OPACITY)
2486 );
2487 let outlined = resolve_colors(Some(&theme), SplitButtonVariant::Outlined, false);
2488 assert_eq!(outlined.container, Color::TRANSPARENT);
2489 assert_eq!(
2490 outlined.outline,
2491 Some(with_alpha(on_surface, DISABLED_CONTAINER_OPACITY))
2492 );
2493 }
2494
2495 #[test]
2496 fn the_gap_is_wider_for_the_elevated_variant() {
2497 assert_eq!(plain(false).base_gap(), SPLIT_INNER_GAP);
2499 assert_eq!(
2500 plain(false)
2501 .variant(SplitButtonVariant::Elevated)
2502 .base_gap(),
2503 SPLIT_ELEVATED_INNER_GAP
2504 );
2505 assert_eq!(plain(false).gap(9.0).base_gap(), 9.0);
2506 assert_eq!(SPLIT_INNER_GAP, 2.0);
2507 assert_eq!(SPLIT_ELEVATED_INNER_GAP, 4.0);
2508 assert_eq!(SPLIT_MIN_TAP_TARGET, 48.0);
2509 }
2510
2511 #[test]
2514 fn the_resting_connected_radii_are_outer_outside_and_inner_at_the_seam() {
2515 for size in SplitButtonSize::ALL {
2516 let mut widget = laid_out(&plain(false).size(size));
2517 let pill = size.height() / 2.0;
2518 let inner = size.inner_radius(None);
2519
2520 let leading = segment(&mut widget, 0).target_radii(None, size.height());
2521 assert_eq!(
2522 leading,
2523 CornerRadii::new(pill, inner, inner, pill),
2524 "{size:?}"
2525 );
2526 let trailing = segment(&mut widget, 1).target_radii(None, size.height());
2527 assert_eq!(
2528 trailing,
2529 CornerRadii::new(inner, pill, pill, inner),
2530 "{size:?}"
2531 );
2532 }
2533 }
2534
2535 #[test]
2536 fn the_square_shape_family_swaps_the_outer_corners_for_the_token_radius() {
2537 for size in SplitButtonSize::ALL {
2538 let mut widget = laid_out(&plain(false).size(size).shape(SplitButtonShape::Square));
2539 let outer = size.square_outer_radius();
2540 let inner = size.inner_radius(None);
2541 assert_eq!(
2542 segment(&mut widget, 0).target_radii(None, size.height()),
2543 CornerRadii::new(outer, inner, inner, outer),
2544 "{size:?}"
2545 );
2546 }
2547 assert_eq!(SplitButtonShape::default(), SplitButtonShape::Round);
2548 }
2549
2550 #[test]
2551 fn the_inner_radius_column_resolves_from_the_shape_scale() {
2552 let theme = crate::baseline();
2554 assert_eq!(
2555 SplitButtonSize::Sm.inner_radius(Some(&theme)),
2556 theme.shape.extra_small
2557 );
2558 assert_eq!(
2559 SplitButtonSize::Lg.inner_radius(Some(&theme)),
2560 theme.shape.small
2561 );
2562 assert_eq!(
2563 SplitButtonSize::Xl.inner_radius(Some(&theme)),
2564 theme.shape.medium
2565 );
2566 assert_eq!(SplitButtonSize::Sm.inner_radius(None), 4.0);
2568 assert_eq!(SplitButtonSize::Lg.inner_radius(None), 8.0);
2569 assert_eq!(SplitButtonSize::Xl.inner_radius(None), 12.0);
2570 assert_eq!(SplitButtonSize::Sm.hovered_inner_radius(), 12.0);
2572 assert_eq!(SplitButtonSize::Sm.pressed_radius(), 2.0);
2573 assert_eq!(SplitButtonSize::Sm.square_outer_radius(), 10.0);
2574 assert_eq!(SplitButtonSize::Xl.hovered_inner_radius(), 20.0);
2575 assert_eq!(SplitButtonSize::Xl.pressed_radius(), 6.0);
2576 }
2577
2578 #[test]
2579 fn pressed_outranks_hovered_at_the_seam() {
2580 let mut widget = laid_out(&plain(false));
2581 let size = SplitButtonSize::Sm;
2582 let seg = segment(&mut widget, 0);
2583 seg.state.set_hovered(true);
2584 assert_eq!(
2585 seg.target_radii(None, size.height()).top_right,
2586 size.hovered_inner_radius()
2587 );
2588 seg.state.set_pressed(true);
2589 assert_eq!(
2590 seg.target_radii(None, size.height()).top_right,
2591 size.pressed_radius(),
2592 "pressed outranks hovered"
2593 );
2594 seg.enabled = false;
2596 assert_eq!(
2597 seg.target_radii(None, size.height()).top_right,
2598 size.inner_radius(None)
2599 );
2600 }
2601
2602 #[test]
2603 fn an_open_trailing_half_goes_fully_round_and_suppresses_hover() {
2604 let mut widget = laid_out(&plain(true).size(SplitButtonSize::Md));
2605 let height = SplitButtonSize::Md.height();
2606 let seg = segment(&mut widget, 1);
2607 seg.state.set_hovered(true);
2608 let radii = seg.target_radii(None, height);
2609 assert_eq!(
2610 radii,
2611 CornerRadii::uniform(height * TRAILING_SELECTED_CORNER_PERCENT / 100.0),
2612 "the open trailing half is a pill on every corner"
2613 );
2614 assert_eq!(TRAILING_SELECTED_CORNER_PERCENT, 50.0);
2615 }
2616
2617 #[test]
2618 fn the_circle_trailing_branch_coincides_with_the_open_branch() {
2619 for size in SplitButtonSize::ALL {
2623 let mut widget = laid_out(&plain(true).size(size));
2624 let seg = segment(&mut widget, 1);
2625 assert_eq!(seg.circle_trailing(), size.allows_circle_trailing());
2626 let height = size.height();
2627 let selected = height * TRAILING_SELECTED_CORNER_PERCENT / 100.0;
2628 assert_eq!(
2629 seg.target_radii(None, height),
2630 CornerRadii::uniform(selected),
2631 "{size:?}"
2632 );
2633 }
2634 }
2635
2636 #[test]
2639 fn the_shape_spring_matches_expressive_spatial_press() {
2640 let token = crate::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
2641 assert_eq!(SPLIT_SHAPE_SPRING.stiffness, token.stiffness);
2642 assert_eq!(SPLIT_SHAPE_SPRING.damping_ratio, token.damping_ratio);
2643 assert_eq!(SPLIT_SHAPE_SPRING.mass, 1.0);
2644 assert_eq!(SPLIT_SHAPE_SPRING.stiffness, 380.0);
2645 assert_eq!(SPLIT_SHAPE_SPRING.damping_ratio, 0.55);
2646 }
2647
2648 #[test]
2649 fn the_shape_morph_snaps_on_mount_then_springs_and_stays_continuous() {
2650 let mut motion = ShapeMotion::new();
2651 let rest = CornerRadii::new(20.0, 4.0, 4.0, 20.0);
2652 assert!(
2653 !motion.retarget(rest),
2654 "the mount frame seeds, never animates"
2655 );
2656 assert_eq!(motion.radii(), rest);
2657 assert!(!motion.advance(ft_secs(0.0)), "nothing to advance");
2658
2659 let nudged = CornerRadii::new(20.0 + RETARGET_TOLERANCE / 2.0, 4.0, 4.0, 20.0);
2661 assert!(!motion.retarget(nudged));
2662 assert_eq!(motion.target_radii(), rest);
2663
2664 let pressed = CornerRadii::new(20.0, 2.0, 2.0, 20.0);
2666 assert!(motion.retarget(pressed));
2667 assert_eq!(motion.radii(), rest, "the leg starts where it was");
2668 motion.advance(ft_secs(0.0));
2669 motion.advance(ft_secs(0.05));
2670 let mid = motion.radii().top_right;
2671 assert!(mid < 4.0 && mid > 2.0, "sampled mid-flight (got {mid})");
2672
2673 assert!(motion.retarget(rest));
2675 assert_eq!(motion.radii().top_right, mid);
2676 let mut t = 0.05;
2677 for _ in 0..600 {
2678 t += 1.0 / 60.0;
2679 if !motion.advance(ft_secs(t)) {
2680 break;
2681 }
2682 assert!(motion.radii().top_right >= 0.0, "a corner went negative");
2683 }
2684 assert_eq!(motion.radii(), rest, "and settles exactly on target");
2685 }
2686
2687 #[test]
2688 fn a_segment_clips_its_content_to_the_animating_radii() {
2689 let mut widget = laid_out(&plain(false));
2690 let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
2691 assert_eq!(rec.clips.len(), 2, "one clip per segment");
2692 assert_eq!(rec.pops, 2, "every clip is popped");
2693 for (index, (clip_origin, clip_size, clip_radii)) in rec.clips.iter().enumerate() {
2695 let (fill_origin, fill_size, fill_radii, _) = rec.rrects[index];
2696 assert_eq!(*clip_origin, fill_origin);
2697 assert_eq!(*clip_size, fill_size);
2698 assert_eq!(*clip_radii, fill_radii);
2699 }
2700 }
2701
2702 #[test]
2703 fn opening_the_menu_springs_the_trailing_clip_toward_the_pill() {
2704 let prev = plain(false).size(SplitButtonSize::Md);
2705 let mut widget = laid_out(&prev);
2706 paint_at(&mut widget, Size::new(300.0, 56.0), None);
2707 let closed = segment(&mut widget, 1).motion.target_radii();
2708
2709 rebuild(&prev, &plain(true).size(SplitButtonSize::Md), &mut widget);
2710 paint_at(&mut widget, Size::new(300.0, 56.0), None);
2711 let seg = segment(&mut widget, 1);
2712 assert_ne!(seg.motion.target_radii(), closed, "the target moved");
2713 assert_eq!(seg.motion.target_radii(), CornerRadii::uniform(28.0));
2714 settle_morph(seg);
2715 assert_eq!(seg.motion.radii(), CornerRadii::uniform(28.0));
2716 }
2717
2718 #[test]
2719 fn the_chevron_rotates_a_half_turn_on_open_over_the_reference_tween() {
2720 assert_eq!(CHEVRON_OPEN_TURNS, 0.5);
2721 assert_eq!(CHEVRON_ROTATION_DURATION, Duration::from_millis(120));
2722
2723 let geometric = |open: bool| {
2726 plain(open).trailing_alignment(SplitButtonTrailingAlignment::GeometricCenter)
2727 };
2728 let prev = geometric(false);
2729 let mut widget = laid_out(&prev);
2730 assert_eq!(segment(&mut widget, 1).chevron_anim.value(), 0.0);
2731
2732 let extent = Size::new(48.0, 40.0);
2733 let glyph_box = |w: &mut SplitButtonWidget| {
2734 segment(w, 1)
2735 .chevron_path(extent)
2736 .expect("the trailing chevron path")
2737 .bounding_box()
2738 };
2739 let closed = glyph_box(&mut widget);
2740
2741 rebuild(&prev, &geometric(true), &mut widget);
2742 let seg = segment(&mut widget, 1);
2744 let mut t = 0.0;
2745 for _ in 0..60 {
2746 t += 1.0 / 60.0;
2747 if !seg.chevron_anim.advance(ft_secs(t)) {
2748 break;
2749 }
2750 }
2751 assert_eq!(
2752 seg.chevron_anim.value(),
2753 1.0,
2754 "the tween lands on a half turn"
2755 );
2756
2757 let open = glyph_box(&mut widget);
2760 let cx = extent.width / 2.0;
2761 let cy = extent.height / 2.0;
2762 assert!((open.center().x - (2.0 * cx - closed.center().x)).abs() < 1e-6);
2763 assert!((open.center().y - (2.0 * cy - closed.center().y)).abs() < 1e-6);
2764 assert!(
2765 closed.center().y > open.center().y,
2766 "the closed chevron hangs below the open one"
2767 );
2768 }
2769
2770 #[test]
2771 fn the_chevron_takes_the_optical_nudge_only_while_closed() {
2772 assert_eq!(SplitButtonSize::Md.menu_icon_offset(), -2.0);
2774 assert_eq!(SplitButtonSize::Xl.menu_icon_offset(), -6.0);
2775
2776 let closed = laid_out(&plain(false).size(SplitButtonSize::Md));
2777 let mut closed = closed;
2778 let box_closed = segment(&mut closed, 1)
2779 .chevron_path(Size::new(56.0, 56.0))
2780 .expect("a chevron")
2781 .bounding_box();
2782
2783 let mut geometric = laid_out(
2784 &plain(false)
2785 .size(SplitButtonSize::Md)
2786 .trailing_alignment(SplitButtonTrailingAlignment::GeometricCenter),
2787 );
2788 let box_geometric = segment(&mut geometric, 1)
2789 .chevron_path(Size::new(56.0, 56.0))
2790 .expect("a chevron")
2791 .bounding_box();
2792
2793 assert!(
2794 (box_closed.center().x - (box_geometric.center().x - 2.0)).abs() < 1e-6,
2795 "the optical centre nudges the glyph leading by the size's offset"
2796 );
2797 assert_eq!(
2798 SplitButtonTrailingAlignment::default(),
2799 SplitButtonTrailingAlignment::OpticalCenter
2800 );
2801 }
2802
2803 #[test]
2806 fn each_half_is_its_own_hit_target() {
2807 let mut widget = laid_out(&plain(false));
2808 let size = layout_with(&mut widget, 600.0, None);
2809 let leading_center = Point::new(widget.segments[0].size().width / 2.0, size.height / 2.0);
2810 let trailing_center = Point::new(
2811 widget.segments[1].origin().x + widget.segments[1].size().width / 2.0,
2812 size.height / 2.0,
2813 );
2814
2815 let mut state = Log::default();
2816 click(
2817 &mut widget,
2818 &mut state,
2819 size,
2820 leading_center.x,
2821 leading_center.y,
2822 );
2823 assert_eq!(state.presses, 1);
2824 assert_eq!(state.opens, 0, "the leading half never opens the menu");
2825
2826 click(
2827 &mut widget,
2828 &mut state,
2829 size,
2830 trailing_center.x,
2831 trailing_center.y,
2832 );
2833 assert_eq!(state.opens, 1);
2834 assert_eq!(state.presses, 1, "the trailing half never fires the action");
2835 }
2836
2837 #[test]
2838 fn a_key_activation_reaches_only_the_focused_half() {
2839 let mut widget = laid_out(&plain(false));
2840 let size = layout_with(&mut widget, 600.0, None);
2841 let mut state = Log::default();
2842
2843 dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2845 assert_eq!((state.presses, state.opens), (0, 0));
2846
2847 widget.segments[0].set_focused(true);
2848 dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2849 assert_eq!(
2850 (state.presses, state.opens),
2851 (1, 0),
2852 "the focused leading half activated"
2853 );
2854
2855 widget.segments[0].set_focused(false);
2856 widget.segments[1].set_focused(true);
2857 dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2858 assert_eq!(
2859 (state.presses, state.opens),
2860 (1, 1),
2861 "focus moved, and so did the activation"
2862 );
2863
2864 dispatch(
2866 &mut widget,
2867 &mut state,
2868 size,
2869 &InputEvent::Key(KeyEvent {
2870 key: Key::Character(" ".to_string()),
2871 modifiers: Modifiers::default(),
2872 repeat: false,
2873 }),
2874 );
2875 assert_eq!(state.opens, 2);
2876 dispatch(
2877 &mut widget,
2878 &mut state,
2879 size,
2880 &InputEvent::Key(KeyEvent {
2881 key: Key::Named(NamedKey::Enter),
2882 modifiers: Modifiers::default(),
2883 repeat: true,
2884 }),
2885 );
2886 assert_eq!(state.opens, 2, "an auto-repeat is not a fresh activation");
2887 }
2888
2889 #[test]
2890 fn the_trailing_half_disables_independently_of_the_leading_one() {
2891 let view = plain(false).trailing_enabled(false);
2892 let mut widget = laid_out(&view);
2893 let size = layout_with(&mut widget, 600.0, None);
2894 let mut state = Log::default();
2895
2896 let trailing_x = widget.segments[1].origin().x + widget.segments[1].size().width / 2.0;
2897 click(&mut widget, &mut state, size, trailing_x, size.height / 2.0);
2898 assert_eq!(state.opens, 0, "a disabled trailing half never arms");
2899
2900 click(&mut widget, &mut state, size, 20.0, size.height / 2.0);
2901 assert_eq!(state.presses, 1, "the leading action is still live");
2902
2903 let mut both_off = laid_out(&plain(false).enabled(false).trailing_enabled(true));
2905 let size = layout_with(&mut both_off, 600.0, None);
2906 let mut state = Log::default();
2907 click(&mut both_off, &mut state, size, 20.0, size.height / 2.0);
2908 let trailing_x = both_off.segments[1].origin().x + 10.0;
2909 click(
2910 &mut both_off,
2911 &mut state,
2912 size,
2913 trailing_x,
2914 size.height / 2.0,
2915 );
2916 assert_eq!((state.presses, state.opens), (0, 0));
2917 }
2918
2919 #[test]
2920 fn focus_on_either_half_widens_the_gap_by_the_ring_outset() {
2921 assert_eq!(SPLIT_FOCUS_RING_OUTSET, 4.0);
2922 let mut widget = laid_out(&plain(false));
2923 layout_with(&mut widget, 600.0, None);
2924 let unfocused = widget.gap;
2925 assert_eq!(unfocused, SPLIT_INNER_GAP);
2926
2927 widget.segments[1].set_focused(true);
2928 layout_with(&mut widget, 600.0, None);
2929 assert_eq!(widget.gap, SPLIT_INNER_GAP + SPLIT_FOCUS_RING_OUTSET);
2930 assert_eq!(
2931 widget.segments[1].origin().x,
2932 widget.segments[0].size().width + widget.gap
2933 );
2934 }
2935
2936 #[test]
2937 fn semantics_yields_a_group_over_two_independently_reporting_buttons() {
2938 fn logic(_s: &mut ()) -> SplitButtonView<()> {
2939 split_button::<(), _, _>("Save", true, |_s: &mut ()| {}, |_s: &mut ()| {})
2940 .trailing_enabled(false)
2941 }
2942 let mut root: frust_core::RenderRoot<(), SplitButtonView<()>> =
2943 frust_core::RenderRoot::new();
2944 let mut state = ();
2945 root.rebuild(&mut logic, &mut state);
2946 let mut tcx = TextContext::new();
2947 root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
2948 let update = root.semantics();
2949
2950 let group = update
2951 .nodes
2952 .iter()
2953 .find(|(_, n)| n.role() == Role::Group)
2954 .expect("a Group container node is contributed");
2955 assert_eq!(group.1.children().len(), 2);
2956
2957 let leading = update
2958 .nodes
2959 .iter()
2960 .find(|(_, n)| n.label() == Some("Save"))
2961 .expect("the leading action node");
2962 assert!(leading.1.supports_action(Action::Click));
2963 assert!(!leading.1.is_disabled());
2964
2965 let trailing = update
2966 .nodes
2967 .iter()
2968 .find(|(_, n)| n.label() == Some(DEFAULT_TRAILING_LABEL))
2969 .expect("the trailing menu node");
2970 assert_eq!(trailing.1.is_expanded(), Some(true));
2971 assert!(
2972 trailing.1.is_disabled(),
2973 "the trailing half reports its own disabled state"
2974 );
2975 assert!(!trailing.1.supports_action(Action::Expand));
2976 }
2977
2978 #[test]
2979 fn trailing_label_reaches_the_trailing_nodes_semantics() {
2980 fn logic(_s: &mut ()) -> SplitButtonView<()> {
2985 split_button::<(), _, _>("Save", true, |_s: &mut ()| {}, |_s: &mut ()| {})
2986 .trailing_label("More actions")
2987 }
2988 let mut root: frust_core::RenderRoot<(), SplitButtonView<()>> =
2989 frust_core::RenderRoot::new();
2990 let mut state = ();
2991 root.rebuild(&mut logic, &mut state);
2992 let mut tcx = TextContext::new();
2993 root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
2994 let update = root.semantics();
2995
2996 let trailing = update
2997 .nodes
2998 .iter()
2999 .find(|(_, n)| n.label() == Some("More actions"))
3000 .expect("the custom trailing label reaches the trailing node");
3001 assert_eq!(trailing.1.is_expanded(), Some(true));
3002 assert!(
3003 update
3004 .nodes
3005 .iter()
3006 .all(|(_, n)| n.label() != Some(DEFAULT_TRAILING_LABEL)),
3007 "a custom trailing label replaces the default, not just adds to it"
3008 );
3009 }
3010
3011 #[test]
3012 fn rebuild_with_a_changed_trailing_label_updates_the_node() {
3013 struct LabelState {
3016 label: &'static str,
3017 }
3018 fn logic(s: &mut LabelState) -> SplitButtonView<LabelState> {
3019 split_button::<LabelState, _, _>(
3020 "Save",
3021 true,
3022 |_s: &mut LabelState| {},
3023 |_s: &mut LabelState| {},
3024 )
3025 .trailing_label(s.label)
3026 }
3027 let mut root: frust_core::RenderRoot<LabelState, SplitButtonView<LabelState>> =
3028 frust_core::RenderRoot::new();
3029 let mut state = LabelState { label: "First" };
3030 let mut tcx = TextContext::new();
3031 root.rebuild(&mut logic, &mut state);
3032 root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3033 let before = root.semantics();
3034 assert!(
3035 before.nodes.iter().any(|(_, n)| n.label() == Some("First")),
3036 "the initial trailing label reaches the node"
3037 );
3038
3039 state.label = "Second";
3040 root.rebuild(&mut logic, &mut state);
3041 root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3042 let after = root.semantics();
3043
3044 assert!(
3045 after.nodes.iter().any(|(_, n)| n.label() == Some("Second")),
3046 "the rebuilt trailing label reaches the node"
3047 );
3048 assert!(
3049 after.nodes.iter().all(|(_, n)| n.label() != Some("First")),
3050 "the stale label is gone after rebuild"
3051 );
3052 }
3053
3054 fn with_items(open: bool) -> SplitButtonView<Log> {
3057 plain(open)
3058 .items(vec![
3059 split_button_item("Save as").value("save-as"),
3060 split_button_item("Export").leading(icons::CHECK),
3061 split_button_item("Delete").enabled(false),
3062 ])
3063 .on_select(|s: &mut Log, selection| s.selections.push(selection))
3064 }
3065
3066 #[test]
3067 fn the_menu_style_selects_the_route() {
3068 let popup = with_items(true);
3069 assert!(matches!(popup.menu_route(), SplitButtonMenuRoute::Popup(_)));
3070 assert_eq!(popup.menu_style, SplitButtonMenuStyle::Popup);
3071 assert_eq!(SplitButtonMenuStyle::default(), SplitButtonMenuStyle::Popup);
3072
3073 let sheet = with_items(true).menu_style(SplitButtonMenuStyle::BottomSheet);
3074 assert!(matches!(sheet.menu_route(), SplitButtonMenuRoute::Sheet(_)));
3075 }
3076
3077 #[test]
3078 fn items_map_onto_selectable_menu_nodes() {
3079 let view = with_items(true);
3080 let nodes = view.menu_nodes();
3081 assert_eq!(nodes.len(), 3);
3082 let labels: Vec<&str> = view.items.iter().map(SplitButtonItem::label).collect();
3083 assert_eq!(labels, ["Save as", "Export", "Delete"]);
3084 assert_eq!(view.items[0].resolved_value(), "save-as");
3085 assert_eq!(
3086 view.items[1].resolved_value(),
3087 "Export",
3088 "an item with no explicit value reports its label"
3089 );
3090 for node in &nodes {
3091 assert!(
3092 matches!(node, MenuNode::Selectable(_)),
3093 "every item maps onto `M3EMenuSelectable`"
3094 );
3095 }
3096 let MenuNode::Selectable(delete) = &nodes[2] else {
3097 panic!("a selectable node");
3098 };
3099 assert_eq!(delete.label(), "Delete");
3100 assert!(
3101 !nodes[2].is_activatable(),
3102 "a disabled item stays disabled in the menu"
3103 );
3104 }
3105
3106 #[test]
3107 fn the_popup_route_places_its_panel_against_the_trailing_segment() {
3108 let anchor = OverlayAnchor::new();
3112 let view = with_items(true).menu_anchor(&anchor);
3113 let mut widget = laid_out(&view);
3114 let size = layout_with(&mut widget, 600.0, None);
3115 paint_at(&mut widget, size, None);
3116
3117 let captured = anchor.rect();
3118 assert_eq!(captured.x0, widget.segments[1].origin().x);
3119 assert_eq!(captured.width(), widget.segments[1].size().width);
3120 assert!(captured.height() > 0.0);
3121 }
3122
3123 #[test]
3124 fn a_menu_selection_reports_through_on_select() {
3125 let anchor = OverlayAnchor::new();
3127 anchor.set(Rect::new(40.0, 100.0, 160.0, 140.0));
3128 let mut root: frust_core::RenderRoot<Log, frust::StackView<Log>> =
3129 frust_core::RenderRoot::new();
3130 let mut state = Log::default();
3131 let mut tcx = TextContext::new();
3132 let cell = anchor.clone();
3133 let mut logic = move |_s: &mut Log| {
3134 frust::Stack(vec![with_items(true).menu_anchor(&cell).popup_menu()])
3135 };
3136 root.rebuild(&mut logic, &mut state);
3137 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3138
3139 let x = 40.0 + crate::menu::MENU_MIN_WIDTH / 2.0;
3142 let y = 140.0 + crate::overlay::OVERLAY_ANCHOR_GAP + 8.0 + (4.0 + 48.0) / 2.0;
3143 for phase in [PointerPhase::Down, PointerPhase::Up] {
3144 root.event(
3145 &mut state,
3146 &InputEvent::Pointer(PointerEvent {
3147 phase,
3148 position: Point::new(x, y),
3149 button: PointerButton::Primary,
3150 }),
3151 );
3152 }
3153 assert_eq!(state.selections.len(), 1);
3154 assert_eq!(state.selections[0].label, "Save as");
3155 assert_eq!(state.selections[0].value(), Some("save-as"));
3156 }
3157
3158 #[test]
3159 fn the_sheet_route_carries_the_same_items() {
3160 let mut root: frust_core::RenderRoot<Log, BottomSheetView<Log>> =
3164 frust_core::RenderRoot::new();
3165 let mut state = Log::default();
3166 let mut tcx = TextContext::new();
3167 let mut logic = move |_s: &mut Log| {
3169 let view = with_items(true).menu_style(SplitButtonMenuStyle::BottomSheet);
3170 match view.menu_route() {
3171 SplitButtonMenuRoute::Sheet(sheet) => sheet,
3172 SplitButtonMenuRoute::Popup(_) => {
3173 panic!("the bottom-sheet style routes to a sheet")
3174 }
3175 }
3176 };
3177 root.rebuild(&mut logic, &mut state);
3178 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3179 let update = root.semantics();
3180 for label in ["Save as", "Export", "Delete"] {
3182 assert!(
3183 update.nodes.iter().any(|(_, n)| n.label() == Some(label)),
3184 "`{label}` is present in the sheet-style menu"
3185 );
3186 }
3187 }
3188
3189 #[test]
3192 fn a_themed_paint_fills_both_halves_with_the_variant_container() {
3193 let theme = crate::baseline().with_brightness(Brightness::Light);
3194 let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Tonal));
3195 let rec = paint_at(&mut widget, Size::new(300.0, 48.0), Some(&theme));
3196 let container = theme.scheme().secondary_container;
3197 assert_eq!(rec.rrects[0].3, container, "the leading container");
3198 assert_eq!(rec.rrects[1].3, container, "the trailing container");
3199 assert!(!rec.runs.is_empty(), "the leading label painted");
3200 assert_eq!(
3201 rec.paths.len(),
3202 1,
3203 "exactly one chevron path, on the trailing half"
3204 );
3205 assert_eq!(rec.paths[0], theme.scheme().on_secondary_container);
3206 }
3207
3208 #[test]
3209 fn an_outlined_split_button_strokes_a_hairline_per_half() {
3210 let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Outlined));
3211 let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
3212 assert_eq!(rec.strokes.len(), 2, "one hairline per segment");
3213 for (width, _) in &rec.strokes {
3214 assert_eq!(*width, OUTLINE_WIDTH);
3215 }
3216 assert!(
3217 rec.rrects.iter().all(|(_, _, _, c)| c.components[3] > 0.0),
3218 "a transparent container is never filled"
3219 );
3220 }
3221
3222 #[test]
3223 fn an_elevated_split_button_casts_a_shadow_per_half() {
3224 let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Elevated));
3225 let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
3226 assert_eq!(rec.shadows, 2);
3227 assert_eq!(
3228 elevation_dp(SplitButtonVariant::Elevated, true, false, false),
3229 1.0
3230 );
3231 assert_eq!(
3232 elevation_dp(SplitButtonVariant::Elevated, true, false, true),
3233 3.0
3234 );
3235 assert_eq!(
3236 elevation_dp(SplitButtonVariant::Elevated, true, true, false),
3237 0.0
3238 );
3239 assert_eq!(
3240 elevation_dp(SplitButtonVariant::Elevated, false, false, false),
3241 0.0
3242 );
3243 }
3244
3245 #[test]
3246 fn a_leading_icon_lays_out_before_the_label_at_the_size_s_gap() {
3247 let with_icon = plain(false)
3248 .size(SplitButtonSize::Md)
3249 .leading_icon(|| any::<Log, _>(frust::icon(icons::CHECK)));
3250 let mut widget = laid_out(&with_icon);
3251 layout_with(&mut widget, 600.0, None);
3252 let seg = segment(&mut widget, 0);
3253 let icon = seg.icon.as_ref().expect("the icon pod");
3254 assert_eq!(icon.origin().x, SplitButtonSize::Md.left_outer_padding());
3255 assert_eq!(icon.size(), Size::new(24.0, 24.0));
3256 assert_eq!(
3257 seg.label_origin.x,
3258 SplitButtonSize::Md.left_outer_padding()
3259 + 24.0
3260 + SplitButtonSize::Md.icon_to_label_gap()
3261 );
3262 }
3263
3264 #[test]
3265 fn the_original_four_argument_surface_still_compiles_and_fires() {
3266 let view: SplitButtonView<Log> = split_button(
3268 "Save",
3269 false,
3270 |s: &mut Log| s.presses += 1,
3271 |s: &mut Log| s.opens += 1,
3272 );
3273 let _pascal: SplitButtonView<Log> = SplitButton(
3274 "Save",
3275 false,
3276 |s: &mut Log| s.presses += 1,
3277 |s: &mut Log| s.opens += 1,
3278 );
3279 let mut widget = laid_out(&view);
3280 let size = layout_with(&mut widget, 600.0, None);
3281 let mut state = Log::default();
3282 click(&mut widget, &mut state, size, 20.0, size.height / 2.0);
3283 assert_eq!(state.presses, 1);
3284 }
3285
3286 #[test]
3287 fn a_release_outside_the_armed_half_fires_nothing() {
3288 let mut widget = laid_out(&plain(false));
3289 let size = layout_with(&mut widget, 600.0, None);
3290 let mut state = Log::default();
3291 let trailing_x = widget.segments[1].origin().x + 10.0;
3292 dispatch(
3293 &mut widget,
3294 &mut state,
3295 size,
3296 &ev(PointerPhase::Down, 20.0, 20.0),
3297 );
3298 dispatch(
3299 &mut widget,
3300 &mut state,
3301 size,
3302 &ev(PointerPhase::Move, trailing_x, 20.0),
3303 );
3304 dispatch(
3305 &mut widget,
3306 &mut state,
3307 size,
3308 &ev(PointerPhase::Up, trailing_x, 20.0),
3309 );
3310 assert_eq!((state.presses, state.opens), (0, 0));
3311 }
3312
3313 #[test]
3314 fn a_cancel_disarms_without_firing() {
3315 let mut widget = laid_out(&plain(false));
3316 let size = layout_with(&mut widget, 600.0, None);
3317 let mut state = Log::default();
3318 let trailing_x = widget.segments[1].origin().x + 10.0;
3319 dispatch(
3320 &mut widget,
3321 &mut state,
3322 size,
3323 &ev(PointerPhase::Down, trailing_x, 20.0),
3324 );
3325 dispatch(
3326 &mut widget,
3327 &mut state,
3328 size,
3329 &ev(PointerPhase::Cancel, trailing_x, 20.0),
3330 );
3331 assert_eq!(state.opens, 0);
3332 assert!(!segment(&mut widget, 1).captured);
3333 }
3334
3335 #[test]
3336 fn a_non_material_theme_still_resolves_every_role() {
3337 let bare = Theme::neutral();
3339 for variant in SplitButtonVariant::ALL {
3340 let _ = resolve_colors(Some(&bare), variant, true);
3341 let _ = resolve_colors(Some(&bare), variant, false);
3342 }
3343 for size in SplitButtonSize::ALL {
3344 let _ = size.inner_radius(Some(&bare));
3345 let _ = size.label_style(Some(&bare));
3346 }
3347 let mut widget = laid_out(&plain(true));
3348 let _ = paint_at(&mut widget, Size::new(300.0, 48.0), Some(&bare));
3349 }
3350}