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, V>(mut self, icon: F) -> Self
1853 where
1854 F: Fn() -> V + 'static,
1855 V: View<State>,
1856 {
1857 self.leading_icon = Some(Rc::new(move || AnyView::new(icon())));
1858 self
1859 }
1860
1861 pub fn trailing_label(mut self, label: impl Into<String>) -> Self {
1865 self.trailing_label = Some(label.into());
1866 self
1867 }
1868
1869 pub fn gap(mut self, gap: f64) -> Self {
1874 self.gap = Some(gap);
1875 self
1876 }
1877
1878 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
1882 self.haptic = haptic;
1883 self
1884 }
1885
1886 pub fn items(mut self, items: Vec<SplitButtonItem>) -> Self {
1889 self.items = items;
1890 self
1891 }
1892
1893 pub fn selected_value(mut self, value: Option<String>) -> Self {
1896 self.selected_value = value;
1897 self
1898 }
1899
1900 pub fn menu_style(mut self, style: SplitButtonMenuStyle) -> Self {
1902 self.menu_style = style;
1903 self
1904 }
1905
1906 pub fn on_select<F: Fn(&mut State, MenuSelection) + 'static>(mut self, on_select: F) -> Self {
1908 self.on_select = Some(Rc::new(on_select));
1909 self
1910 }
1911
1912 pub fn menu_anchor(mut self, anchor: &OverlayAnchor) -> Self {
1917 self.menu_anchor = anchor.clone();
1918 self
1919 }
1920
1921 fn trailing_is_enabled(&self) -> bool {
1923 self.enabled && self.trailing_enabled
1924 }
1925
1926 fn base_gap(&self) -> f64 {
1929 self.gap
1930 .unwrap_or(if self.variant == SplitButtonVariant::Elevated {
1931 SPLIT_ELEVATED_INNER_GAP
1932 } else {
1933 SPLIT_INNER_GAP
1934 })
1935 }
1936
1937 fn menu_nodes(&self) -> Vec<MenuNode> {
1939 self.items
1940 .iter()
1941 .map(SplitButtonItem::to_menu_node)
1942 .collect()
1943 }
1944
1945 pub fn popup_menu(&self) -> AnyView<State> {
1951 let on_select = self.on_select.clone();
1952 any(
1953 menu(self.menu_nodes(), move |state: &mut State, selection| {
1954 if let Some(callback) = &on_select {
1955 callback(state, selection);
1956 }
1957 })
1958 .anchor(&self.menu_anchor)
1959 .selected(self.selected_value.clone())
1960 .open(self.open),
1961 )
1962 }
1963
1964 pub fn sheet_menu(&self) -> BottomSheetView<State> {
1969 let on_select = self.on_select.clone();
1970 bottom_sheet(
1971 menu_panel(self.menu_nodes(), move |state: &mut State, selection| {
1972 if let Some(callback) = &on_select {
1973 callback(state, selection);
1974 }
1975 })
1976 .selected(self.selected_value.clone()),
1977 )
1978 }
1979
1980 pub fn menu_route(&self) -> SplitButtonMenuRoute<State> {
1984 match self.menu_style {
1985 SplitButtonMenuStyle::Popup => SplitButtonMenuRoute::Popup(self.popup_menu()),
1986 SplitButtonMenuStyle::BottomSheet => SplitButtonMenuRoute::Sheet(self.sheet_menu()),
1987 }
1988 }
1989
1990 fn leading_view(&self) -> SegmentView<State> {
1992 let on_press = self.on_press.clone();
1993 SegmentView {
1994 kind: SegmentKind::Leading,
1995 size: self.size,
1996 shape: self.shape,
1997 variant: self.variant,
1998 enabled: self.enabled,
1999 open: self.open,
2000 trailing_alignment: self.trailing_alignment,
2001 label: self.label.clone(),
2002 icon: self.leading_icon.as_ref().map(|factory| factory()),
2003 haptic: self.haptic,
2004 anchor: None,
2005 on_press,
2006 }
2007 }
2008
2009 fn trailing_view(&self) -> SegmentView<State> {
2011 let on_open = self.on_open.clone();
2012 SegmentView {
2013 kind: SegmentKind::Trailing,
2014 size: self.size,
2015 shape: self.shape,
2016 variant: self.variant,
2017 enabled: self.trailing_is_enabled(),
2018 open: self.open,
2019 trailing_alignment: self.trailing_alignment,
2020 label: self
2021 .trailing_label
2022 .clone()
2023 .unwrap_or_else(|| DEFAULT_TRAILING_LABEL.to_string()),
2024 icon: None,
2025 haptic: self.haptic,
2026 anchor: Some(self.menu_anchor.clone()),
2027 on_press: on_open,
2028 }
2029 }
2030}
2031
2032pub struct SplitButtonWidget {
2036 segments: Vec<ChildPod>,
2038 size: SplitButtonSize,
2039 base_gap: f64,
2040 gap: f64,
2042}
2043
2044impl SplitButtonWidget {
2045 fn resolved_gap(&self) -> f64 {
2049 let focused = self.segments.iter().any(ChildPod::is_focused);
2050 self.base_gap
2051 + if focused {
2052 SPLIT_FOCUS_RING_OUTSET
2053 } else {
2054 0.0
2055 }
2056 }
2057}
2058
2059impl<State: 'static> View<State> for SplitButtonView<State> {
2060 type Element = SplitButtonWidget;
2061
2062 fn build(&self, ctx: &mut BuildCtx<'_>) -> SplitButtonWidget {
2063 SplitButtonWidget {
2064 segments: vec![
2065 build_segment(&self.leading_view(), ctx),
2066 build_segment(&self.trailing_view(), ctx),
2067 ],
2068 size: self.size,
2069 base_gap: self.base_gap(),
2070 gap: self.base_gap(),
2071 }
2072 }
2073
2074 fn rebuild(
2075 &self,
2076 prev: &Self,
2077 element: &mut SplitButtonWidget,
2078 ctx: &mut BuildCtx<'_>,
2079 ) -> ChangeFlags {
2080 let mut flags = ChangeFlags::NONE;
2081 if prev.size != self.size {
2082 element.size = self.size;
2083 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2084 }
2085 let base_gap = self.base_gap();
2086 if element.base_gap != base_gap {
2087 element.base_gap = base_gap;
2088 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2089 }
2090 flags |= View::<State>::rebuild(
2091 &self.leading_view(),
2092 &prev.leading_view(),
2093 segment_of(&mut element.segments[0]),
2094 ctx,
2095 );
2096 flags |= View::<State>::rebuild(
2097 &self.trailing_view(),
2098 &prev.trailing_view(),
2099 segment_of(&mut element.segments[1]),
2100 ctx,
2101 );
2102 flags
2103 }
2104
2105 fn teardown(&self, element: &mut SplitButtonWidget, ctx: &mut BuildCtx<'_>) {
2106 View::<State>::teardown(
2107 &self.leading_view(),
2108 segment_of(&mut element.segments[0]),
2109 ctx,
2110 );
2111 View::<State>::teardown(
2112 &self.trailing_view(),
2113 segment_of(&mut element.segments[1]),
2114 ctx,
2115 );
2116 }
2117}
2118
2119impl Widget for SplitButtonWidget {
2120 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2121 let gap = self.resolved_gap();
2122 self.gap = gap;
2123 let segment_height = self.size.height();
2124 let height = segment_height.max(SPLIT_MIN_TAP_TARGET);
2127
2128 let trailing = self.segments[1].layout_child(
2131 ctx,
2132 &BoxConstraints::loose(Size::new(bc.max().width, segment_height)),
2133 );
2134 let leading_max = if bc.max().width.is_finite() {
2135 (bc.max().width - gap - trailing.width).max(0.0)
2136 } else {
2137 f64::INFINITY
2138 };
2139 let leading = self.segments[0].layout_child(
2140 ctx,
2141 &BoxConstraints::loose(Size::new(leading_max, segment_height)),
2142 );
2143
2144 self.segments[0].set_origin(Point::new(0.0, (height - leading.height) / 2.0));
2145 self.segments[1].set_origin(Point::new(
2146 leading.width + gap,
2147 (height - trailing.height) / 2.0,
2148 ));
2149 bc.constrain(Size::new(leading.width + gap + trailing.width, height))
2150 }
2151
2152 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2153 for pod in &mut self.segments {
2154 pod.paint_child(ctx, scene);
2155 }
2156 }
2157
2158 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2159 route_event(&mut self.segments, ctx, event)
2164 }
2165
2166 fn semantics(&self, ctx: &mut SemanticsCtx) {
2167 ctx.push_container(
2171 Role::Group,
2172 |_| {},
2173 |ctx| {
2174 for pod in &self.segments {
2175 pod.semantics_child(ctx);
2176 }
2177 },
2178 );
2179 }
2180
2181 visit_children!(segments);
2182}
2183
2184#[cfg(test)]
2185mod tests {
2186 use super::*;
2187 use frust::Brightness;
2188 use frust::authoring::scene::GlyphRun;
2189 use frust::authoring::{KeyEvent, Modifiers, PointerButton, PointerEvent};
2190 use std::any::Any;
2191
2192 #[derive(Default)]
2195 struct Log {
2196 presses: u32,
2197 opens: u32,
2198 selections: Vec<MenuSelection>,
2199 }
2200
2201 fn ft_secs(s: f64) -> FrameTime {
2202 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
2203 }
2204
2205 fn build<S: 'static>(view: &SplitButtonView<S>) -> SplitButtonWidget {
2206 let mut counter = 0u64;
2207 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
2208 }
2209
2210 fn rebuild<S: 'static>(
2211 prev: &SplitButtonView<S>,
2212 next: &SplitButtonView<S>,
2213 widget: &mut SplitButtonWidget,
2214 ) {
2215 let mut counter = 0u64;
2216 View::<S>::rebuild(next, prev, widget, &mut BuildCtx::new(&mut counter));
2217 }
2218
2219 fn plain(open: bool) -> SplitButtonView<Log> {
2220 split_button::<Log, _, _>(
2221 "Save",
2222 open,
2223 |s: &mut Log| s.presses += 1,
2224 |s: &mut Log| s.opens += 1,
2225 )
2226 }
2227
2228 fn layout_with(widget: &mut SplitButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
2229 let mut tcx = TextContext::new();
2230 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2231 if let Some(theme) = theme {
2232 lctx = lctx.with_theme(theme as &dyn Any);
2233 }
2234 widget.layout(
2235 &mut lctx,
2236 &BoxConstraints::loose(Size::new(max_width, 500.0)),
2237 )
2238 }
2239
2240 fn laid_out(view: &SplitButtonView<Log>) -> SplitButtonWidget {
2241 let mut widget = build(view);
2242 layout_with(&mut widget, 600.0, None);
2243 widget
2244 }
2245
2246 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
2247 InputEvent::Pointer(PointerEvent {
2248 phase,
2249 position: Point::new(x, y),
2250 button: PointerButton::Primary,
2251 })
2252 }
2253
2254 fn key(named: NamedKey) -> InputEvent {
2255 InputEvent::Key(KeyEvent {
2256 key: Key::Named(named),
2257 modifiers: Modifiers::default(),
2258 repeat: false,
2259 })
2260 }
2261
2262 fn dispatch(widget: &mut SplitButtonWidget, state: &mut Log, size: Size, event: &InputEvent) {
2263 let state_any: &mut dyn Any = state;
2264 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2265 widget.event(&mut ctx, event);
2266 }
2267
2268 fn click(widget: &mut SplitButtonWidget, state: &mut Log, size: Size, x: f64, y: f64) {
2270 dispatch(widget, state, size, &ev(PointerPhase::Down, x, y));
2271 dispatch(widget, state, size, &ev(PointerPhase::Up, x, y));
2272 }
2273
2274 fn segment(widget: &mut SplitButtonWidget, index: usize) -> &mut SegmentWidget {
2275 segment_of(&mut widget.segments[index])
2276 }
2277
2278 #[derive(Default)]
2279 struct Recorder {
2280 rrects: Vec<(Point, Size, CornerRadii, Color)>,
2281 clips: Vec<(Point, Size, CornerRadii)>,
2282 pops: usize,
2283 paths: Vec<Color>,
2284 strokes: Vec<(f64, Color)>,
2285 runs: Vec<(Point, Color)>,
2286 shadows: usize,
2287 }
2288
2289 fn solid(brush: &Brush) -> Color {
2290 match brush {
2291 Brush::Solid(c) => *c,
2292 _ => Color::TRANSPARENT,
2293 }
2294 }
2295
2296 impl PaintScene for Recorder {
2297 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
2298 fn draw_text(&mut self, _o: Point, _t: &str) {}
2299 fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
2300 self.rrects.push((o, s, radii, color));
2301 }
2302 fn push_clip_rounded_radii(&mut self, o: Point, s: Size, radii: CornerRadii) {
2303 self.clips.push((o, s, radii));
2304 }
2305 fn pop_clip(&mut self) {
2306 self.pops += 1;
2307 }
2308 fn fill_path(&mut self, _o: Point, _p: &BezPath, brush: &Brush) {
2309 self.paths.push(solid(brush));
2310 }
2311 fn stroke_path(&mut self, _o: Point, _p: &BezPath, width: f64, brush: &Brush) {
2312 self.strokes.push((width, solid(brush)));
2313 }
2314 fn draw_glyph_run(&mut self, run: GlyphRun) {
2315 let t = run.transform.translation();
2316 self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
2317 }
2318 fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _sd: f64, _c: Color) {
2319 self.shadows += 1;
2320 }
2321 }
2322
2323 fn paint_at(widget: &mut SplitButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
2324 let mut rec = Recorder::default();
2325 let mut pctx = PaintCtx::new(Point::ZERO, size);
2326 if let Some(theme) = theme {
2327 pctx = pctx.with_theme(theme as &dyn Any);
2328 }
2329 widget.paint(&mut pctx, &mut rec);
2330 rec
2331 }
2332
2333 fn settle_morph(segment: &mut SegmentWidget) {
2335 let mut t = 0.0;
2336 for _ in 0..600 {
2337 t += 1.0 / 60.0;
2338 if !segment.motion.advance(ft_secs(t)) {
2339 return;
2340 }
2341 }
2342 panic!("the shape morph never settled");
2343 }
2344
2345 #[test]
2348 fn every_size_row_matches_the_reference_theme_tables() {
2349 let table = [
2353 (SplitButtonSize::Xs, 32.0, 22.0, 12.0, 10.0, 20.0),
2354 (SplitButtonSize::Sm, 40.0, 22.0, 16.0, 12.0, 24.0),
2355 (SplitButtonSize::Md, 56.0, 26.0, 24.0, 24.0, 24.0),
2356 (SplitButtonSize::Lg, 96.0, 38.0, 48.0, 48.0, 32.0),
2357 (SplitButtonSize::Xl, 136.0, 50.0, 64.0, 64.0, 40.0),
2358 ];
2359 for (size, height, chevron, left, right, icon) in table {
2360 assert_eq!(size.height(), height, "{size:?} height");
2361 assert_eq!(size.trailing_icon_size(), chevron, "{size:?} chevron");
2362 assert_eq!(size.left_outer_padding(), left, "{size:?} left pad");
2363 assert_eq!(size.label_right_padding(), right, "{size:?} right pad");
2364 assert_eq!(size.icon_size(), icon, "{size:?} icon");
2365 }
2366 assert_eq!(SplitButtonSize::default(), SplitButtonSize::Sm);
2367 assert_eq!(SplitButtonSize::ALL.len(), 5);
2368 }
2369
2370 #[test]
2371 fn the_three_trailing_width_formulas_agree() {
2372 for size in SplitButtonSize::ALL {
2377 assert_eq!(
2378 size.trailing_width_unselected(),
2379 size.trailing_width_selected(),
2380 "{size:?}"
2381 );
2382 if size.allows_circle_trailing() {
2383 assert_eq!(
2384 size.trailing_width_unselected(),
2385 size.height(),
2386 "{size:?} circle width is the segment height"
2387 );
2388 }
2389 assert_eq!(
2390 size.trailing_width(),
2391 size.trailing_width_unselected().max(SPLIT_MIN_TAP_TARGET)
2392 );
2393 }
2394 assert_eq!(SplitButtonSize::Xs.trailing_width(), 48.0);
2395 assert_eq!(SplitButtonSize::Md.trailing_width(), 56.0);
2396 assert_eq!(SplitButtonSize::Xl.trailing_width(), 136.0);
2397 }
2398
2399 #[test]
2400 fn each_size_lays_its_segments_out_at_the_table_height_and_trailing_width() {
2401 for size in SplitButtonSize::ALL {
2402 let mut widget = laid_out(&plain(false).size(size));
2403 let expected_height = size.height().max(SPLIT_MIN_TAP_TARGET);
2404 let outer = layout_with(&mut widget, 600.0, None);
2405 assert_eq!(outer.height, expected_height, "{size:?} outer height");
2406 assert_eq!(
2407 widget.segments[0].size().height,
2408 size.height(),
2409 "{size:?} leading segment height"
2410 );
2411 assert_eq!(
2412 widget.segments[1].size().width,
2413 size.trailing_width(),
2414 "{size:?} trailing segment width"
2415 );
2416 let leading_right = widget.segments[0].size().width;
2418 assert_eq!(
2419 widget.segments[1].origin().x,
2420 leading_right + SPLIT_INNER_GAP,
2421 "{size:?} gap"
2422 );
2423 }
2424 }
2425
2426 #[test]
2427 fn each_variant_resolves_its_own_container_and_content_roles() {
2428 let theme = crate::baseline().with_brightness(Brightness::Light);
2431 let s = theme.scheme();
2432 let expected = [
2433 (SplitButtonVariant::Filled, s.primary, s.on_primary, false),
2434 (
2435 SplitButtonVariant::Tonal,
2436 s.secondary_container,
2437 s.on_secondary_container,
2438 false,
2439 ),
2440 (
2441 SplitButtonVariant::Elevated,
2442 s.surface_container_low,
2443 s.primary,
2444 false,
2445 ),
2446 (
2447 SplitButtonVariant::Outlined,
2448 Color::TRANSPARENT,
2449 s.primary,
2450 true,
2451 ),
2452 ];
2453 for (variant, container, content, outlined) in expected {
2454 let colors = resolve_colors(Some(&theme), variant, true);
2455 assert_eq!(colors.container, container, "{variant:?} container");
2456 assert_eq!(colors.content, content, "{variant:?} content");
2457 assert_eq!(colors.outline.is_some(), outlined, "{variant:?} outline");
2458 assert_eq!(colors.focus_ring, s.primary, "{variant:?} focus ring");
2459 }
2460 assert_eq!(SplitButtonVariant::default(), SplitButtonVariant::Filled);
2461 assert_eq!(SplitButtonVariant::ALL.len(), 4);
2462 }
2463
2464 #[test]
2465 fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
2466 let light = crate::baseline().with_brightness(Brightness::Light);
2467 for variant in SplitButtonVariant::ALL {
2468 for enabled in [true, false] {
2469 assert_eq!(
2470 resolve_colors(None, variant, enabled),
2471 resolve_colors(Some(&light), variant, enabled),
2472 "{variant:?} enabled={enabled}"
2473 );
2474 }
2475 }
2476 }
2477
2478 #[test]
2479 fn a_disabled_segment_takes_the_reference_disabled_alphas() {
2480 let theme = crate::baseline().with_brightness(Brightness::Light);
2483 let on_surface = theme.scheme().on_surface;
2484 let filled = resolve_colors(Some(&theme), SplitButtonVariant::Filled, false);
2485 assert_eq!(
2486 filled.content,
2487 with_alpha(on_surface, DISABLED_CONTENT_OPACITY)
2488 );
2489 assert_eq!(
2490 filled.container,
2491 with_alpha(on_surface, DISABLED_CONTAINER_OPACITY)
2492 );
2493 let outlined = resolve_colors(Some(&theme), SplitButtonVariant::Outlined, false);
2494 assert_eq!(outlined.container, Color::TRANSPARENT);
2495 assert_eq!(
2496 outlined.outline,
2497 Some(with_alpha(on_surface, DISABLED_CONTAINER_OPACITY))
2498 );
2499 }
2500
2501 #[test]
2502 fn the_gap_is_wider_for_the_elevated_variant() {
2503 assert_eq!(plain(false).base_gap(), SPLIT_INNER_GAP);
2505 assert_eq!(
2506 plain(false)
2507 .variant(SplitButtonVariant::Elevated)
2508 .base_gap(),
2509 SPLIT_ELEVATED_INNER_GAP
2510 );
2511 assert_eq!(plain(false).gap(9.0).base_gap(), 9.0);
2512 assert_eq!(SPLIT_INNER_GAP, 2.0);
2513 assert_eq!(SPLIT_ELEVATED_INNER_GAP, 4.0);
2514 assert_eq!(SPLIT_MIN_TAP_TARGET, 48.0);
2515 }
2516
2517 #[test]
2520 fn the_resting_connected_radii_are_outer_outside_and_inner_at_the_seam() {
2521 for size in SplitButtonSize::ALL {
2522 let mut widget = laid_out(&plain(false).size(size));
2523 let pill = size.height() / 2.0;
2524 let inner = size.inner_radius(None);
2525
2526 let leading = segment(&mut widget, 0).target_radii(None, size.height());
2527 assert_eq!(
2528 leading,
2529 CornerRadii::new(pill, inner, inner, pill),
2530 "{size:?}"
2531 );
2532 let trailing = segment(&mut widget, 1).target_radii(None, size.height());
2533 assert_eq!(
2534 trailing,
2535 CornerRadii::new(inner, pill, pill, inner),
2536 "{size:?}"
2537 );
2538 }
2539 }
2540
2541 #[test]
2542 fn the_square_shape_family_swaps_the_outer_corners_for_the_token_radius() {
2543 for size in SplitButtonSize::ALL {
2544 let mut widget = laid_out(&plain(false).size(size).shape(SplitButtonShape::Square));
2545 let outer = size.square_outer_radius();
2546 let inner = size.inner_radius(None);
2547 assert_eq!(
2548 segment(&mut widget, 0).target_radii(None, size.height()),
2549 CornerRadii::new(outer, inner, inner, outer),
2550 "{size:?}"
2551 );
2552 }
2553 assert_eq!(SplitButtonShape::default(), SplitButtonShape::Round);
2554 }
2555
2556 #[test]
2557 fn the_inner_radius_column_resolves_from_the_shape_scale() {
2558 let theme = crate::baseline();
2560 assert_eq!(
2561 SplitButtonSize::Sm.inner_radius(Some(&theme)),
2562 theme.shape.extra_small
2563 );
2564 assert_eq!(
2565 SplitButtonSize::Lg.inner_radius(Some(&theme)),
2566 theme.shape.small
2567 );
2568 assert_eq!(
2569 SplitButtonSize::Xl.inner_radius(Some(&theme)),
2570 theme.shape.medium
2571 );
2572 assert_eq!(SplitButtonSize::Sm.inner_radius(None), 4.0);
2574 assert_eq!(SplitButtonSize::Lg.inner_radius(None), 8.0);
2575 assert_eq!(SplitButtonSize::Xl.inner_radius(None), 12.0);
2576 assert_eq!(SplitButtonSize::Sm.hovered_inner_radius(), 12.0);
2578 assert_eq!(SplitButtonSize::Sm.pressed_radius(), 2.0);
2579 assert_eq!(SplitButtonSize::Sm.square_outer_radius(), 10.0);
2580 assert_eq!(SplitButtonSize::Xl.hovered_inner_radius(), 20.0);
2581 assert_eq!(SplitButtonSize::Xl.pressed_radius(), 6.0);
2582 }
2583
2584 #[test]
2585 fn pressed_outranks_hovered_at_the_seam() {
2586 let mut widget = laid_out(&plain(false));
2587 let size = SplitButtonSize::Sm;
2588 let seg = segment(&mut widget, 0);
2589 seg.state.set_hovered(true);
2590 assert_eq!(
2591 seg.target_radii(None, size.height()).top_right,
2592 size.hovered_inner_radius()
2593 );
2594 seg.state.set_pressed(true);
2595 assert_eq!(
2596 seg.target_radii(None, size.height()).top_right,
2597 size.pressed_radius(),
2598 "pressed outranks hovered"
2599 );
2600 seg.enabled = false;
2602 assert_eq!(
2603 seg.target_radii(None, size.height()).top_right,
2604 size.inner_radius(None)
2605 );
2606 }
2607
2608 #[test]
2609 fn an_open_trailing_half_goes_fully_round_and_suppresses_hover() {
2610 let mut widget = laid_out(&plain(true).size(SplitButtonSize::Md));
2611 let height = SplitButtonSize::Md.height();
2612 let seg = segment(&mut widget, 1);
2613 seg.state.set_hovered(true);
2614 let radii = seg.target_radii(None, height);
2615 assert_eq!(
2616 radii,
2617 CornerRadii::uniform(height * TRAILING_SELECTED_CORNER_PERCENT / 100.0),
2618 "the open trailing half is a pill on every corner"
2619 );
2620 assert_eq!(TRAILING_SELECTED_CORNER_PERCENT, 50.0);
2621 }
2622
2623 #[test]
2624 fn the_circle_trailing_branch_coincides_with_the_open_branch() {
2625 for size in SplitButtonSize::ALL {
2629 let mut widget = laid_out(&plain(true).size(size));
2630 let seg = segment(&mut widget, 1);
2631 assert_eq!(seg.circle_trailing(), size.allows_circle_trailing());
2632 let height = size.height();
2633 let selected = height * TRAILING_SELECTED_CORNER_PERCENT / 100.0;
2634 assert_eq!(
2635 seg.target_radii(None, height),
2636 CornerRadii::uniform(selected),
2637 "{size:?}"
2638 );
2639 }
2640 }
2641
2642 #[test]
2645 fn the_shape_spring_matches_expressive_spatial_press() {
2646 let token = crate::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
2647 assert_eq!(SPLIT_SHAPE_SPRING.stiffness, token.stiffness);
2648 assert_eq!(SPLIT_SHAPE_SPRING.damping_ratio, token.damping_ratio);
2649 assert_eq!(SPLIT_SHAPE_SPRING.mass, 1.0);
2650 assert_eq!(SPLIT_SHAPE_SPRING.stiffness, 380.0);
2651 assert_eq!(SPLIT_SHAPE_SPRING.damping_ratio, 0.55);
2652 }
2653
2654 #[test]
2655 fn the_shape_morph_snaps_on_mount_then_springs_and_stays_continuous() {
2656 let mut motion = ShapeMotion::new();
2657 let rest = CornerRadii::new(20.0, 4.0, 4.0, 20.0);
2658 assert!(
2659 !motion.retarget(rest),
2660 "the mount frame seeds, never animates"
2661 );
2662 assert_eq!(motion.radii(), rest);
2663 assert!(!motion.advance(ft_secs(0.0)), "nothing to advance");
2664
2665 let nudged = CornerRadii::new(20.0 + RETARGET_TOLERANCE / 2.0, 4.0, 4.0, 20.0);
2667 assert!(!motion.retarget(nudged));
2668 assert_eq!(motion.target_radii(), rest);
2669
2670 let pressed = CornerRadii::new(20.0, 2.0, 2.0, 20.0);
2672 assert!(motion.retarget(pressed));
2673 assert_eq!(motion.radii(), rest, "the leg starts where it was");
2674 motion.advance(ft_secs(0.0));
2675 motion.advance(ft_secs(0.05));
2676 let mid = motion.radii().top_right;
2677 assert!(mid < 4.0 && mid > 2.0, "sampled mid-flight (got {mid})");
2678
2679 assert!(motion.retarget(rest));
2681 assert_eq!(motion.radii().top_right, mid);
2682 let mut t = 0.05;
2683 for _ in 0..600 {
2684 t += 1.0 / 60.0;
2685 if !motion.advance(ft_secs(t)) {
2686 break;
2687 }
2688 assert!(motion.radii().top_right >= 0.0, "a corner went negative");
2689 }
2690 assert_eq!(motion.radii(), rest, "and settles exactly on target");
2691 }
2692
2693 #[test]
2694 fn a_segment_clips_its_content_to_the_animating_radii() {
2695 let mut widget = laid_out(&plain(false));
2696 let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
2697 assert_eq!(rec.clips.len(), 2, "one clip per segment");
2698 assert_eq!(rec.pops, 2, "every clip is popped");
2699 for (index, (clip_origin, clip_size, clip_radii)) in rec.clips.iter().enumerate() {
2701 let (fill_origin, fill_size, fill_radii, _) = rec.rrects[index];
2702 assert_eq!(*clip_origin, fill_origin);
2703 assert_eq!(*clip_size, fill_size);
2704 assert_eq!(*clip_radii, fill_radii);
2705 }
2706 }
2707
2708 #[test]
2709 fn opening_the_menu_springs_the_trailing_clip_toward_the_pill() {
2710 let prev = plain(false).size(SplitButtonSize::Md);
2711 let mut widget = laid_out(&prev);
2712 paint_at(&mut widget, Size::new(300.0, 56.0), None);
2713 let closed = segment(&mut widget, 1).motion.target_radii();
2714
2715 rebuild(&prev, &plain(true).size(SplitButtonSize::Md), &mut widget);
2716 paint_at(&mut widget, Size::new(300.0, 56.0), None);
2717 let seg = segment(&mut widget, 1);
2718 assert_ne!(seg.motion.target_radii(), closed, "the target moved");
2719 assert_eq!(seg.motion.target_radii(), CornerRadii::uniform(28.0));
2720 settle_morph(seg);
2721 assert_eq!(seg.motion.radii(), CornerRadii::uniform(28.0));
2722 }
2723
2724 #[test]
2725 fn the_chevron_rotates_a_half_turn_on_open_over_the_reference_tween() {
2726 assert_eq!(CHEVRON_OPEN_TURNS, 0.5);
2727 assert_eq!(CHEVRON_ROTATION_DURATION, Duration::from_millis(120));
2728
2729 let geometric = |open: bool| {
2732 plain(open).trailing_alignment(SplitButtonTrailingAlignment::GeometricCenter)
2733 };
2734 let prev = geometric(false);
2735 let mut widget = laid_out(&prev);
2736 assert_eq!(segment(&mut widget, 1).chevron_anim.value(), 0.0);
2737
2738 let extent = Size::new(48.0, 40.0);
2739 let glyph_box = |w: &mut SplitButtonWidget| {
2740 segment(w, 1)
2741 .chevron_path(extent)
2742 .expect("the trailing chevron path")
2743 .bounding_box()
2744 };
2745 let closed = glyph_box(&mut widget);
2746
2747 rebuild(&prev, &geometric(true), &mut widget);
2748 let seg = segment(&mut widget, 1);
2750 let mut t = 0.0;
2751 for _ in 0..60 {
2752 t += 1.0 / 60.0;
2753 if !seg.chevron_anim.advance(ft_secs(t)) {
2754 break;
2755 }
2756 }
2757 assert_eq!(
2758 seg.chevron_anim.value(),
2759 1.0,
2760 "the tween lands on a half turn"
2761 );
2762
2763 let open = glyph_box(&mut widget);
2766 let cx = extent.width / 2.0;
2767 let cy = extent.height / 2.0;
2768 assert!((open.center().x - (2.0 * cx - closed.center().x)).abs() < 1e-6);
2769 assert!((open.center().y - (2.0 * cy - closed.center().y)).abs() < 1e-6);
2770 assert!(
2771 closed.center().y > open.center().y,
2772 "the closed chevron hangs below the open one"
2773 );
2774 }
2775
2776 #[test]
2777 fn the_chevron_takes_the_optical_nudge_only_while_closed() {
2778 assert_eq!(SplitButtonSize::Md.menu_icon_offset(), -2.0);
2780 assert_eq!(SplitButtonSize::Xl.menu_icon_offset(), -6.0);
2781
2782 let closed = laid_out(&plain(false).size(SplitButtonSize::Md));
2783 let mut closed = closed;
2784 let box_closed = segment(&mut closed, 1)
2785 .chevron_path(Size::new(56.0, 56.0))
2786 .expect("a chevron")
2787 .bounding_box();
2788
2789 let mut geometric = laid_out(
2790 &plain(false)
2791 .size(SplitButtonSize::Md)
2792 .trailing_alignment(SplitButtonTrailingAlignment::GeometricCenter),
2793 );
2794 let box_geometric = segment(&mut geometric, 1)
2795 .chevron_path(Size::new(56.0, 56.0))
2796 .expect("a chevron")
2797 .bounding_box();
2798
2799 assert!(
2800 (box_closed.center().x - (box_geometric.center().x - 2.0)).abs() < 1e-6,
2801 "the optical centre nudges the glyph leading by the size's offset"
2802 );
2803 assert_eq!(
2804 SplitButtonTrailingAlignment::default(),
2805 SplitButtonTrailingAlignment::OpticalCenter
2806 );
2807 }
2808
2809 #[test]
2812 fn each_half_is_its_own_hit_target() {
2813 let mut widget = laid_out(&plain(false));
2814 let size = layout_with(&mut widget, 600.0, None);
2815 let leading_center = Point::new(widget.segments[0].size().width / 2.0, size.height / 2.0);
2816 let trailing_center = Point::new(
2817 widget.segments[1].origin().x + widget.segments[1].size().width / 2.0,
2818 size.height / 2.0,
2819 );
2820
2821 let mut state = Log::default();
2822 click(
2823 &mut widget,
2824 &mut state,
2825 size,
2826 leading_center.x,
2827 leading_center.y,
2828 );
2829 assert_eq!(state.presses, 1);
2830 assert_eq!(state.opens, 0, "the leading half never opens the menu");
2831
2832 click(
2833 &mut widget,
2834 &mut state,
2835 size,
2836 trailing_center.x,
2837 trailing_center.y,
2838 );
2839 assert_eq!(state.opens, 1);
2840 assert_eq!(state.presses, 1, "the trailing half never fires the action");
2841 }
2842
2843 #[test]
2844 fn a_key_activation_reaches_only_the_focused_half() {
2845 let mut widget = laid_out(&plain(false));
2846 let size = layout_with(&mut widget, 600.0, None);
2847 let mut state = Log::default();
2848
2849 dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2851 assert_eq!((state.presses, state.opens), (0, 0));
2852
2853 widget.segments[0].set_focused(true);
2854 dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2855 assert_eq!(
2856 (state.presses, state.opens),
2857 (1, 0),
2858 "the focused leading half activated"
2859 );
2860
2861 widget.segments[0].set_focused(false);
2862 widget.segments[1].set_focused(true);
2863 dispatch(&mut widget, &mut state, size, &key(NamedKey::Enter));
2864 assert_eq!(
2865 (state.presses, state.opens),
2866 (1, 1),
2867 "focus moved, and so did the activation"
2868 );
2869
2870 dispatch(
2872 &mut widget,
2873 &mut state,
2874 size,
2875 &InputEvent::Key(KeyEvent {
2876 key: Key::Character(" ".to_string()),
2877 modifiers: Modifiers::default(),
2878 repeat: false,
2879 }),
2880 );
2881 assert_eq!(state.opens, 2);
2882 dispatch(
2883 &mut widget,
2884 &mut state,
2885 size,
2886 &InputEvent::Key(KeyEvent {
2887 key: Key::Named(NamedKey::Enter),
2888 modifiers: Modifiers::default(),
2889 repeat: true,
2890 }),
2891 );
2892 assert_eq!(state.opens, 2, "an auto-repeat is not a fresh activation");
2893 }
2894
2895 #[test]
2896 fn the_trailing_half_disables_independently_of_the_leading_one() {
2897 let view = plain(false).trailing_enabled(false);
2898 let mut widget = laid_out(&view);
2899 let size = layout_with(&mut widget, 600.0, None);
2900 let mut state = Log::default();
2901
2902 let trailing_x = widget.segments[1].origin().x + widget.segments[1].size().width / 2.0;
2903 click(&mut widget, &mut state, size, trailing_x, size.height / 2.0);
2904 assert_eq!(state.opens, 0, "a disabled trailing half never arms");
2905
2906 click(&mut widget, &mut state, size, 20.0, size.height / 2.0);
2907 assert_eq!(state.presses, 1, "the leading action is still live");
2908
2909 let mut both_off = laid_out(&plain(false).enabled(false).trailing_enabled(true));
2911 let size = layout_with(&mut both_off, 600.0, None);
2912 let mut state = Log::default();
2913 click(&mut both_off, &mut state, size, 20.0, size.height / 2.0);
2914 let trailing_x = both_off.segments[1].origin().x + 10.0;
2915 click(
2916 &mut both_off,
2917 &mut state,
2918 size,
2919 trailing_x,
2920 size.height / 2.0,
2921 );
2922 assert_eq!((state.presses, state.opens), (0, 0));
2923 }
2924
2925 #[test]
2926 fn focus_on_either_half_widens_the_gap_by_the_ring_outset() {
2927 assert_eq!(SPLIT_FOCUS_RING_OUTSET, 4.0);
2928 let mut widget = laid_out(&plain(false));
2929 layout_with(&mut widget, 600.0, None);
2930 let unfocused = widget.gap;
2931 assert_eq!(unfocused, SPLIT_INNER_GAP);
2932
2933 widget.segments[1].set_focused(true);
2934 layout_with(&mut widget, 600.0, None);
2935 assert_eq!(widget.gap, SPLIT_INNER_GAP + SPLIT_FOCUS_RING_OUTSET);
2936 assert_eq!(
2937 widget.segments[1].origin().x,
2938 widget.segments[0].size().width + widget.gap
2939 );
2940 }
2941
2942 #[test]
2943 fn semantics_yields_a_group_over_two_independently_reporting_buttons() {
2944 fn logic(_s: &mut ()) -> SplitButtonView<()> {
2945 split_button::<(), _, _>("Save", true, |_s: &mut ()| {}, |_s: &mut ()| {})
2946 .trailing_enabled(false)
2947 }
2948 let mut root: frust_core::RenderRoot<(), SplitButtonView<()>> =
2949 frust_core::RenderRoot::new();
2950 let mut state = ();
2951 root.rebuild(&mut logic, &mut state);
2952 let mut tcx = TextContext::new();
2953 root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
2954 let update = root.semantics();
2955
2956 let group = update
2957 .nodes
2958 .iter()
2959 .find(|(_, n)| n.role() == Role::Group)
2960 .expect("a Group container node is contributed");
2961 assert_eq!(group.1.children().len(), 2);
2962
2963 let leading = update
2964 .nodes
2965 .iter()
2966 .find(|(_, n)| n.label() == Some("Save"))
2967 .expect("the leading action node");
2968 assert!(leading.1.supports_action(Action::Click));
2969 assert!(!leading.1.is_disabled());
2970
2971 let trailing = update
2972 .nodes
2973 .iter()
2974 .find(|(_, n)| n.label() == Some(DEFAULT_TRAILING_LABEL))
2975 .expect("the trailing menu node");
2976 assert_eq!(trailing.1.is_expanded(), Some(true));
2977 assert!(
2978 trailing.1.is_disabled(),
2979 "the trailing half reports its own disabled state"
2980 );
2981 assert!(!trailing.1.supports_action(Action::Expand));
2982 }
2983
2984 #[test]
2985 fn trailing_label_reaches_the_trailing_nodes_semantics() {
2986 fn logic(_s: &mut ()) -> SplitButtonView<()> {
2991 split_button::<(), _, _>("Save", true, |_s: &mut ()| {}, |_s: &mut ()| {})
2992 .trailing_label("More actions")
2993 }
2994 let mut root: frust_core::RenderRoot<(), SplitButtonView<()>> =
2995 frust_core::RenderRoot::new();
2996 let mut state = ();
2997 root.rebuild(&mut logic, &mut state);
2998 let mut tcx = TextContext::new();
2999 root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3000 let update = root.semantics();
3001
3002 let trailing = update
3003 .nodes
3004 .iter()
3005 .find(|(_, n)| n.label() == Some("More actions"))
3006 .expect("the custom trailing label reaches the trailing node");
3007 assert_eq!(trailing.1.is_expanded(), Some(true));
3008 assert!(
3009 update
3010 .nodes
3011 .iter()
3012 .all(|(_, n)| n.label() != Some(DEFAULT_TRAILING_LABEL)),
3013 "a custom trailing label replaces the default, not just adds to it"
3014 );
3015 }
3016
3017 #[test]
3018 fn rebuild_with_a_changed_trailing_label_updates_the_node() {
3019 struct LabelState {
3022 label: &'static str,
3023 }
3024 fn logic(s: &mut LabelState) -> SplitButtonView<LabelState> {
3025 split_button::<LabelState, _, _>(
3026 "Save",
3027 true,
3028 |_s: &mut LabelState| {},
3029 |_s: &mut LabelState| {},
3030 )
3031 .trailing_label(s.label)
3032 }
3033 let mut root: frust_core::RenderRoot<LabelState, SplitButtonView<LabelState>> =
3034 frust_core::RenderRoot::new();
3035 let mut state = LabelState { label: "First" };
3036 let mut tcx = TextContext::new();
3037 root.rebuild(&mut logic, &mut state);
3038 root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3039 let before = root.semantics();
3040 assert!(
3041 before.nodes.iter().any(|(_, n)| n.label() == Some("First")),
3042 "the initial trailing label reaches the node"
3043 );
3044
3045 state.label = "Second";
3046 root.rebuild(&mut logic, &mut state);
3047 root.layout_with_text(Size::new(400.0, 80.0), &mut tcx as &mut dyn Any);
3048 let after = root.semantics();
3049
3050 assert!(
3051 after.nodes.iter().any(|(_, n)| n.label() == Some("Second")),
3052 "the rebuilt trailing label reaches the node"
3053 );
3054 assert!(
3055 after.nodes.iter().all(|(_, n)| n.label() != Some("First")),
3056 "the stale label is gone after rebuild"
3057 );
3058 }
3059
3060 fn with_items(open: bool) -> SplitButtonView<Log> {
3063 plain(open)
3064 .items(vec![
3065 split_button_item("Save as").value("save-as"),
3066 split_button_item("Export").leading(icons::CHECK),
3067 split_button_item("Delete").enabled(false),
3068 ])
3069 .on_select(|s: &mut Log, selection| s.selections.push(selection))
3070 }
3071
3072 #[test]
3073 fn the_menu_style_selects_the_route() {
3074 let popup = with_items(true);
3075 assert!(matches!(popup.menu_route(), SplitButtonMenuRoute::Popup(_)));
3076 assert_eq!(popup.menu_style, SplitButtonMenuStyle::Popup);
3077 assert_eq!(SplitButtonMenuStyle::default(), SplitButtonMenuStyle::Popup);
3078
3079 let sheet = with_items(true).menu_style(SplitButtonMenuStyle::BottomSheet);
3080 assert!(matches!(sheet.menu_route(), SplitButtonMenuRoute::Sheet(_)));
3081 }
3082
3083 #[test]
3084 fn items_map_onto_selectable_menu_nodes() {
3085 let view = with_items(true);
3086 let nodes = view.menu_nodes();
3087 assert_eq!(nodes.len(), 3);
3088 let labels: Vec<&str> = view.items.iter().map(SplitButtonItem::label).collect();
3089 assert_eq!(labels, ["Save as", "Export", "Delete"]);
3090 assert_eq!(view.items[0].resolved_value(), "save-as");
3091 assert_eq!(
3092 view.items[1].resolved_value(),
3093 "Export",
3094 "an item with no explicit value reports its label"
3095 );
3096 for node in &nodes {
3097 assert!(
3098 matches!(node, MenuNode::Selectable(_)),
3099 "every item maps onto `M3EMenuSelectable`"
3100 );
3101 }
3102 let MenuNode::Selectable(delete) = &nodes[2] else {
3103 panic!("a selectable node");
3104 };
3105 assert_eq!(delete.label(), "Delete");
3106 assert!(
3107 !nodes[2].is_activatable(),
3108 "a disabled item stays disabled in the menu"
3109 );
3110 }
3111
3112 #[test]
3113 fn the_popup_route_places_its_panel_against_the_trailing_segment() {
3114 let anchor = OverlayAnchor::new();
3118 let view = with_items(true).menu_anchor(&anchor);
3119 let mut widget = laid_out(&view);
3120 let size = layout_with(&mut widget, 600.0, None);
3121 paint_at(&mut widget, size, None);
3122
3123 let captured = anchor.rect();
3124 assert_eq!(captured.x0, widget.segments[1].origin().x);
3125 assert_eq!(captured.width(), widget.segments[1].size().width);
3126 assert!(captured.height() > 0.0);
3127 }
3128
3129 #[test]
3130 fn a_menu_selection_reports_through_on_select() {
3131 let anchor = OverlayAnchor::new();
3133 anchor.set(Rect::new(40.0, 100.0, 160.0, 140.0));
3134 let mut root: frust_core::RenderRoot<Log, frust::StackView<Log>> =
3135 frust_core::RenderRoot::new();
3136 let mut state = Log::default();
3137 let mut tcx = TextContext::new();
3138 let cell = anchor.clone();
3139 let mut logic = move |_s: &mut Log| {
3140 frust::stack().child(with_items(true).menu_anchor(&cell).popup_menu())
3141 };
3142 root.rebuild(&mut logic, &mut state);
3143 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3144
3145 let x = 40.0 + crate::menu::MENU_MIN_WIDTH / 2.0;
3148 let y = 140.0 + crate::overlay::OVERLAY_ANCHOR_GAP + 8.0 + (4.0 + 48.0) / 2.0;
3149 for phase in [PointerPhase::Down, PointerPhase::Up] {
3150 root.event(
3151 &mut state,
3152 &InputEvent::Pointer(PointerEvent {
3153 phase,
3154 position: Point::new(x, y),
3155 button: PointerButton::Primary,
3156 }),
3157 );
3158 }
3159 assert_eq!(state.selections.len(), 1);
3160 assert_eq!(state.selections[0].label, "Save as");
3161 assert_eq!(state.selections[0].value(), Some("save-as"));
3162 }
3163
3164 #[test]
3165 fn the_sheet_route_carries_the_same_items() {
3166 let mut root: frust_core::RenderRoot<Log, BottomSheetView<Log>> =
3170 frust_core::RenderRoot::new();
3171 let mut state = Log::default();
3172 let mut tcx = TextContext::new();
3173 let mut logic = move |_s: &mut Log| {
3175 let view = with_items(true).menu_style(SplitButtonMenuStyle::BottomSheet);
3176 match view.menu_route() {
3177 SplitButtonMenuRoute::Sheet(sheet) => sheet,
3178 SplitButtonMenuRoute::Popup(_) => {
3179 panic!("the bottom-sheet style routes to a sheet")
3180 }
3181 }
3182 };
3183 root.rebuild(&mut logic, &mut state);
3184 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
3185 let update = root.semantics();
3186 for label in ["Save as", "Export", "Delete"] {
3188 assert!(
3189 update.nodes.iter().any(|(_, n)| n.label() == Some(label)),
3190 "`{label}` is present in the sheet-style menu"
3191 );
3192 }
3193 }
3194
3195 #[test]
3198 fn a_themed_paint_fills_both_halves_with_the_variant_container() {
3199 let theme = crate::baseline().with_brightness(Brightness::Light);
3200 let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Tonal));
3201 let rec = paint_at(&mut widget, Size::new(300.0, 48.0), Some(&theme));
3202 let container = theme.scheme().secondary_container;
3203 assert_eq!(rec.rrects[0].3, container, "the leading container");
3204 assert_eq!(rec.rrects[1].3, container, "the trailing container");
3205 assert!(!rec.runs.is_empty(), "the leading label painted");
3206 assert_eq!(
3207 rec.paths.len(),
3208 1,
3209 "exactly one chevron path, on the trailing half"
3210 );
3211 assert_eq!(rec.paths[0], theme.scheme().on_secondary_container);
3212 }
3213
3214 #[test]
3215 fn an_outlined_split_button_strokes_a_hairline_per_half() {
3216 let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Outlined));
3217 let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
3218 assert_eq!(rec.strokes.len(), 2, "one hairline per segment");
3219 for (width, _) in &rec.strokes {
3220 assert_eq!(*width, OUTLINE_WIDTH);
3221 }
3222 assert!(
3223 rec.rrects.iter().all(|(_, _, _, c)| c.components[3] > 0.0),
3224 "a transparent container is never filled"
3225 );
3226 }
3227
3228 #[test]
3229 fn an_elevated_split_button_casts_a_shadow_per_half() {
3230 let mut widget = laid_out(&plain(false).variant(SplitButtonVariant::Elevated));
3231 let rec = paint_at(&mut widget, Size::new(300.0, 48.0), None);
3232 assert_eq!(rec.shadows, 2);
3233 assert_eq!(
3234 elevation_dp(SplitButtonVariant::Elevated, true, false, false),
3235 1.0
3236 );
3237 assert_eq!(
3238 elevation_dp(SplitButtonVariant::Elevated, true, false, true),
3239 3.0
3240 );
3241 assert_eq!(
3242 elevation_dp(SplitButtonVariant::Elevated, true, true, false),
3243 0.0
3244 );
3245 assert_eq!(
3246 elevation_dp(SplitButtonVariant::Elevated, false, false, false),
3247 0.0
3248 );
3249 }
3250
3251 #[test]
3252 fn a_leading_icon_lays_out_before_the_label_at_the_size_s_gap() {
3253 let with_icon = plain(false)
3254 .size(SplitButtonSize::Md)
3255 .leading_icon(|| frust::icon(icons::CHECK));
3256 let mut widget = laid_out(&with_icon);
3257 layout_with(&mut widget, 600.0, None);
3258 let seg = segment(&mut widget, 0);
3259 let icon = seg.icon.as_ref().expect("the icon pod");
3260 assert_eq!(icon.origin().x, SplitButtonSize::Md.left_outer_padding());
3261 assert_eq!(icon.size(), Size::new(24.0, 24.0));
3262 assert_eq!(
3263 seg.label_origin.x,
3264 SplitButtonSize::Md.left_outer_padding()
3265 + 24.0
3266 + SplitButtonSize::Md.icon_to_label_gap()
3267 );
3268 }
3269
3270 #[test]
3271 fn leading_icon_accepts_a_concrete_view_or_an_any_view() {
3272 let concrete = plain(false).leading_icon(|| frust::icon(icons::CHECK));
3273 let erased = plain(false).leading_icon(|| any::<Log, _>(frust::icon(icons::CHECK)));
3274 let mut a = laid_out(&concrete);
3275 let mut b = laid_out(&erased);
3276 assert_eq!(a.segments.len(), b.segments.len());
3277 assert!(segment(&mut a, 0).icon.is_some());
3278 assert!(segment(&mut b, 0).icon.is_some());
3279 assert_eq!(
3280 segment(&mut a, 0).icon.as_ref().unwrap().size(),
3281 segment(&mut b, 0).icon.as_ref().unwrap().size()
3282 );
3283 }
3284
3285 #[test]
3286 fn the_original_four_argument_surface_still_compiles_and_fires() {
3287 let view: SplitButtonView<Log> = split_button(
3289 "Save",
3290 false,
3291 |s: &mut Log| s.presses += 1,
3292 |s: &mut Log| s.opens += 1,
3293 );
3294 let _pascal: SplitButtonView<Log> = SplitButton(
3295 "Save",
3296 false,
3297 |s: &mut Log| s.presses += 1,
3298 |s: &mut Log| s.opens += 1,
3299 );
3300 let mut widget = laid_out(&view);
3301 let size = layout_with(&mut widget, 600.0, None);
3302 let mut state = Log::default();
3303 click(&mut widget, &mut state, size, 20.0, size.height / 2.0);
3304 assert_eq!(state.presses, 1);
3305 }
3306
3307 #[test]
3308 fn a_release_outside_the_armed_half_fires_nothing() {
3309 let mut widget = laid_out(&plain(false));
3310 let size = layout_with(&mut widget, 600.0, None);
3311 let mut state = Log::default();
3312 let trailing_x = widget.segments[1].origin().x + 10.0;
3313 dispatch(
3314 &mut widget,
3315 &mut state,
3316 size,
3317 &ev(PointerPhase::Down, 20.0, 20.0),
3318 );
3319 dispatch(
3320 &mut widget,
3321 &mut state,
3322 size,
3323 &ev(PointerPhase::Move, trailing_x, 20.0),
3324 );
3325 dispatch(
3326 &mut widget,
3327 &mut state,
3328 size,
3329 &ev(PointerPhase::Up, trailing_x, 20.0),
3330 );
3331 assert_eq!((state.presses, state.opens), (0, 0));
3332 }
3333
3334 #[test]
3335 fn a_cancel_disarms_without_firing() {
3336 let mut widget = laid_out(&plain(false));
3337 let size = layout_with(&mut widget, 600.0, None);
3338 let mut state = Log::default();
3339 let trailing_x = widget.segments[1].origin().x + 10.0;
3340 dispatch(
3341 &mut widget,
3342 &mut state,
3343 size,
3344 &ev(PointerPhase::Down, trailing_x, 20.0),
3345 );
3346 dispatch(
3347 &mut widget,
3348 &mut state,
3349 size,
3350 &ev(PointerPhase::Cancel, trailing_x, 20.0),
3351 );
3352 assert_eq!(state.opens, 0);
3353 assert!(!segment(&mut widget, 1).captured);
3354 }
3355
3356 #[test]
3357 fn a_non_material_theme_still_resolves_every_role() {
3358 let bare = Theme::neutral();
3360 for variant in SplitButtonVariant::ALL {
3361 let _ = resolve_colors(Some(&bare), variant, true);
3362 let _ = resolve_colors(Some(&bare), variant, false);
3363 }
3364 for size in SplitButtonSize::ALL {
3365 let _ = size.inner_radius(Some(&bare));
3366 let _ = size.label_style(Some(&bare));
3367 }
3368 let mut widget = laid_out(&plain(true));
3369 let _ = paint_at(&mut widget, Size::new(300.0, 48.0), Some(&bare));
3370 }
3371}