1use std::rc::Rc;
184use std::time::Duration;
185
186use frust::authoring::{
187 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
188 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
189 PointerPhase, Role, ScrollDelta, SemanticsCtx, View, Widget,
190};
191use frust::input::{TOUCH_SLOP, WHEEL_LINE_PX};
192use frust::{AnimationController, SpringDesc, Theme};
193use kurbo::{Point, RoundedRectRadii, Size};
194
195use crate::interaction::HapticSignal;
196use crate::menu::{MenuNode, MenuSelection, menu, menu_toggleable};
197use crate::overlay::{OverlayAnchor, overlay_anchor};
198use crate::press::presses;
199use crate::{ButtonVariant, ToggleButtonSize};
200
201const STANDARD_SPACING: f64 = 8.0;
208
209const CONNECTED_GAP: f64 = 2.0;
214
215const COMPACT_SCALE: f64 = 0.75;
218
219const DEFAULT_EXPANDED_RATIO: f64 = 0.15;
223
224const SQUISH_SPRING: SpringDesc = SpringDesc {
230 mass: 1.0,
231 stiffness: 1200.0,
232 damping_ratio: 0.8,
233};
234
235const SQUISH_ANIM_PERIOD: Duration = Duration::from_millis(300);
239
240const FLING_VELOCITY: f64 = 4.0;
243
244const SQUISH_REST_EPSILON: f64 = 1e-3;
247
248const FIT_EPSILON: f64 = 0.5;
251
252pub const OUTER_RADIUS: f64 = 16.0;
256pub const INNER_RADIUS: f64 = 4.0;
260
261#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
266pub enum ButtonGroupType {
267 #[default]
270 Standard,
271 Connected,
274}
275
276#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
279pub enum ButtonGroupDensity {
280 #[default]
282 Regular,
283 Compact,
285}
286
287#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
290pub enum ButtonGroupDirection {
291 #[default]
293 Horizontal,
294 Vertical,
296}
297
298#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
303pub enum ButtonGroupOverflow {
304 None,
306 #[default]
309 Scroll,
310 BottomSheet,
316 Popup,
321}
322
323impl ButtonGroupDensity {
324 fn scale(self, raw: f64) -> f64 {
327 match self {
328 ButtonGroupDensity::Regular => raw,
329 ButtonGroupDensity::Compact => (raw * COMPACT_SCALE).floor(),
330 }
331 }
332}
333
334pub fn resolve_spacing(
339 explicit: Option<f64>,
340 group_type: ButtonGroupType,
341 density: ButtonGroupDensity,
342) -> f64 {
343 match explicit {
344 Some(spacing) => spacing,
345 None => match group_type {
346 ButtonGroupType::Connected => CONNECTED_GAP,
347 ButtonGroupType::Standard => density.scale(STANDARD_SPACING),
348 },
349 }
350}
351
352pub fn squish_sizes(
363 natural: &[f64],
364 pressed: Option<usize>,
365 expanded_ratio: f64,
366 anim: f64,
367) -> Vec<f64> {
368 let mut sizes = natural.to_vec();
369 let count = sizes.len();
370 let Some(index) = pressed.filter(|i| *i < count) else {
371 return sizes;
372 };
373 if anim <= 0.0 {
374 return sizes;
375 }
376
377 let growth = natural[index] * expanded_ratio * anim;
378 sizes[index] += growth;
379 if count < 2 {
380 return sizes;
382 }
383 if index > 0 && index + 1 < count {
384 let half = growth / 2.0;
385 sizes[index - 1] = (sizes[index - 1] - half).max(0.0);
386 sizes[index + 1] = (sizes[index + 1] - half).max(0.0);
387 } else if index == 0 {
388 sizes[1] = (sizes[1] - growth).max(0.0);
389 } else {
390 sizes[index - 1] = (sizes[index - 1] - growth).max(0.0);
391 }
392 sizes
393}
394
395pub fn visible_count_for_overflow(
405 max_main: f64,
406 item_extents: &[f64],
407 trigger_extent: f64,
408 separator: f64,
409) -> usize {
410 let available = max_main.floor();
411 let trigger = trigger_extent.ceil();
412 let separator = separator.ceil();
413 let mut current = 0.0_f64;
414 let mut visible = 0usize;
415
416 for (i, extent) in item_extents.iter().enumerate() {
417 let gap_before = if i == 0 { 0.0 } else { separator };
418 let remaining_after = item_extents.len() - i - 1;
419 let reserved = if remaining_after > 0 {
420 separator + trigger
421 } else {
422 0.0
423 };
424 let next = current + gap_before + extent.ceil();
425 if next + reserved < available {
426 current = next;
427 visible = i + 1;
428 } else {
429 break;
430 }
431 }
432 visible
433}
434
435pub fn member_radii(index: usize, count: usize, outer: f64, inner: f64) -> RoundedRectRadii {
452 let is_first = index == 0;
453 let is_last = index + 1 == count;
454 let left = if is_first { outer } else { inner };
456 let right = if is_last { outer } else { inner };
457 RoundedRectRadii::new(left, right, right, left)
458}
459
460type IconFactory<State> = Rc<dyn Fn() -> AnyView<State>>;
470
471fn erase_icon<State: 'static, F, V>(icon: F) -> IconFactory<State>
473where
474 F: Fn() -> V + 'static,
475 V: View<State>,
476{
477 Rc::new(move || AnyView::new(icon()))
478}
479
480pub struct ButtonGroupAction<State: 'static> {
488 icon: Option<IconFactory<State>>,
489 checked_icon: Option<IconFactory<State>>,
490 label: Option<String>,
491 checked_label: Option<String>,
492 checked: Option<bool>,
493 enabled: bool,
494 width: Option<f64>,
495}
496
497pub fn button_group_action<State: 'static>(label: impl Into<String>) -> ButtonGroupAction<State> {
499 ButtonGroupAction {
500 icon: None,
501 checked_icon: None,
502 label: Some(label.into()),
503 checked_label: None,
504 checked: None,
505 enabled: true,
506 width: None,
507 }
508}
509
510pub fn button_group_icon_action<State: 'static, F, V>(icon: F) -> ButtonGroupAction<State>
514where
515 F: Fn() -> V + 'static,
516 V: View<State>,
517{
518 ButtonGroupAction {
519 icon: Some(erase_icon(icon)),
520 checked_icon: None,
521 label: None,
522 checked_label: None,
523 checked: None,
524 enabled: true,
525 width: None,
526 }
527}
528
529impl<State: 'static> ButtonGroupAction<State> {
530 pub fn icon<F, V>(mut self, icon: F) -> Self
533 where
534 F: Fn() -> V + 'static,
535 V: View<State>,
536 {
537 self.icon = Some(erase_icon(icon));
538 self
539 }
540
541 pub fn checked_icon<F, V>(mut self, icon: F) -> Self
543 where
544 F: Fn() -> V + 'static,
545 V: View<State>,
546 {
547 self.checked_icon = Some(erase_icon(icon));
548 self
549 }
550
551 pub fn label(mut self, label: impl Into<String>) -> Self {
554 self.label = Some(label.into());
555 self
556 }
557
558 pub fn checked_label(mut self, label: impl Into<String>) -> Self {
560 self.checked_label = Some(label.into());
561 self
562 }
563
564 pub fn checked(mut self, checked: bool) -> Self {
568 self.checked = Some(checked);
569 self
570 }
571
572 pub fn enabled(mut self, enabled: bool) -> Self {
574 self.enabled = enabled;
575 self
576 }
577
578 pub fn width(mut self, width: f64) -> Self {
581 self.width = Some(width);
582 self
583 }
584
585 fn accessible_label(&self) -> String {
588 self.checked_label
589 .clone()
590 .or_else(|| self.label.clone())
591 .unwrap_or_default()
592 }
593}
594
595type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
599type OnIndexChanged<State> = Rc<dyn Fn(&mut State, Option<usize>)>;
601type OnIndicesChanged<State> = Rc<dyn Fn(&mut State, Vec<usize>)>;
603type OnOverflow<State> = Rc<dyn Fn(&mut State, usize)>;
605
606pub struct ButtonGroupView<State: 'static> {
608 actions: Vec<ButtonGroupAction<State>>,
609 group_type: ButtonGroupType,
610 density: ButtonGroupDensity,
611 direction: ButtonGroupDirection,
612 spacing: Option<f64>,
613 variant: ButtonVariant,
614 size: ToggleButtonSize,
615 haptic: HapticSignal,
616 selected: Option<usize>,
617 selected_indices: Option<Vec<usize>>,
618 on_select: Option<OnSelect<State>>,
619 on_index_changed: Option<OnIndexChanged<State>>,
620 on_indices_changed: Option<OnIndicesChanged<State>>,
621 neighbor_squish: bool,
622 expanded_ratio: f64,
623 overflow: ButtonGroupOverflow,
624 overflow_icon: Option<IconFactory<State>>,
625 on_overflow: Option<OnOverflow<State>>,
626 overflow_anchor: Option<OverlayAnchor>,
627 semantic_label: Option<String>,
628}
629
630pub fn button_group_actions<State: 'static>(
640 actions: impl IntoIterator<Item = ButtonGroupAction<State>>,
641) -> ButtonGroupView<State> {
642 ButtonGroupView {
643 actions: actions.into_iter().collect(),
644 group_type: ButtonGroupType::default(),
645 density: ButtonGroupDensity::default(),
646 direction: ButtonGroupDirection::default(),
647 spacing: None,
648 variant: ButtonVariant::default(),
649 size: ToggleButtonSize::default(),
650 haptic: HapticSignal::None,
651 selected: None,
652 selected_indices: None,
653 on_select: None,
654 on_index_changed: None,
655 on_indices_changed: None,
656 neighbor_squish: true,
657 expanded_ratio: DEFAULT_EXPANDED_RATIO,
658 overflow: ButtonGroupOverflow::default(),
659 overflow_icon: None,
660 on_overflow: None,
661 overflow_anchor: None,
662 semantic_label: None,
663 }
664}
665
666pub fn button_group<State: 'static, F: Fn(&mut State, usize) + 'static>(
680 labels: impl IntoIterator<Item = impl Into<String>>,
681 selected: usize,
682 on_select: F,
683) -> ButtonGroupView<State> {
684 let actions = labels
685 .into_iter()
686 .map(|label| button_group_action::<State>(label));
687 button_group_actions(actions)
688 .group_type(ButtonGroupType::Connected)
689 .selected_index(Some(selected))
690 .on_select(on_select)
691}
692
693#[allow(non_snake_case)]
696pub fn ButtonGroup<State: 'static, F: Fn(&mut State, usize) + 'static>(
697 labels: impl IntoIterator<Item = impl Into<String>>,
698 selected: usize,
699 on_select: F,
700) -> ButtonGroupView<State> {
701 button_group(labels, selected, on_select)
702}
703
704impl<State: 'static> ButtonGroupView<State> {
705 pub fn group_type(mut self, group_type: ButtonGroupType) -> Self {
707 self.group_type = group_type;
708 self
709 }
710
711 pub fn density(mut self, density: ButtonGroupDensity) -> Self {
713 self.density = density;
714 self
715 }
716
717 pub fn direction(mut self, direction: ButtonGroupDirection) -> Self {
719 self.direction = direction;
720 self
721 }
722
723 pub fn spacing(mut self, spacing: f64) -> Self {
725 self.spacing = Some(spacing);
726 self
727 }
728
729 pub fn variant(mut self, variant: ButtonVariant) -> Self {
732 self.variant = variant;
733 self
734 }
735
736 pub fn size(mut self, size: ToggleButtonSize) -> Self {
738 self.size = size;
739 self
740 }
741
742 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
744 self.haptic = haptic;
745 self
746 }
747
748 pub fn selected_index(mut self, selected: Option<usize>) -> Self {
750 self.selected = selected;
751 self
752 }
753
754 pub fn selected_indices(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
757 let mut indices: Vec<usize> = indices.into_iter().collect();
758 indices.sort_unstable();
759 indices.dedup();
760 self.selected_indices = Some(indices);
761 self
762 }
763
764 pub fn on_select<F: Fn(&mut State, usize) + 'static>(mut self, on_select: F) -> Self {
767 self.on_select = Some(Rc::new(on_select));
768 self
769 }
770
771 pub fn on_selected_index_changed<F: Fn(&mut State, Option<usize>) + 'static>(
774 mut self,
775 on_change: F,
776 ) -> Self {
777 self.on_index_changed = Some(Rc::new(on_change));
778 self
779 }
780
781 pub fn on_selected_indices_changed<F: Fn(&mut State, Vec<usize>) + 'static>(
784 mut self,
785 on_change: F,
786 ) -> Self {
787 self.on_indices_changed = Some(Rc::new(on_change));
788 self
789 }
790
791 pub fn neighbor_squish(mut self, squish: bool) -> Self {
794 self.neighbor_squish = squish;
795 self
796 }
797
798 pub fn expanded_ratio(mut self, ratio: f64) -> Self {
801 self.expanded_ratio = ratio;
802 self
803 }
804
805 pub fn overflow(mut self, overflow: ButtonGroupOverflow) -> Self {
808 self.overflow = overflow;
809 self
810 }
811
812 pub fn overflow_icon<F, V>(mut self, icon: F) -> Self
815 where
816 F: Fn() -> V + 'static,
817 V: View<State>,
818 {
819 self.overflow_icon = Some(erase_icon(icon));
820 self
821 }
822
823 pub fn on_overflow<F: Fn(&mut State, usize) + 'static>(mut self, on_overflow: F) -> Self {
829 self.on_overflow = Some(Rc::new(on_overflow));
830 self
831 }
832
833 pub fn overflow_anchor(mut self, anchor: &OverlayAnchor) -> Self {
840 self.overflow_anchor = Some(anchor.clone());
841 self
842 }
843
844 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
847 self.semantic_label = Some(label.into());
848 self
849 }
850
851 fn is_selected(&self, index: usize) -> bool {
855 if let Some(set) = &self.selected_indices {
856 return set.contains(&index);
857 }
858 if self.on_index_changed.is_some() || self.selected.is_some() {
859 return self.selected == Some(index);
860 }
861 self.actions[index].checked.unwrap_or(false)
862 }
863
864 fn member_view(&self, index: usize) -> AnyView<State> {
867 let action = &self.actions[index];
868 let count = self.actions.len();
869 let connected = self.group_type == ButtonGroupType::Connected;
870
871 let on_indices = self.on_indices_changed.clone();
872 let on_index = self.on_index_changed.clone();
873 let on_select = self.on_select.clone();
874 let current = self.selected_indices.clone().unwrap_or_default();
875
876 let mut member = crate::toggle_button::toggle_button::<State, _>(
877 self.is_selected(index),
878 move |state: &mut State, checked: bool| {
879 if let Some(callback) = &on_indices {
880 let mut next = current.clone();
881 if checked {
882 if !next.contains(&index) {
883 next.push(index);
884 next.sort_unstable();
885 }
886 } else {
887 next.retain(|i| *i != index);
888 }
889 callback(state, next);
890 } else if let Some(callback) = &on_index {
891 callback(state, checked.then_some(index));
892 } else if let Some(callback) = &on_select {
893 callback(state, index);
894 }
895 },
896 )
897 .variant(self.variant)
898 .size(self.size)
899 .enabled(action.enabled)
900 .haptic(self.haptic)
901 .group_connected(connected)
902 .first_in_group(index == 0)
903 .last_in_group(index + 1 == count);
908
909 if let Some(icon) = &action.icon {
910 member = member.icon(icon());
911 }
912 if let Some(icon) = &action.checked_icon {
913 member = member.checked_icon(icon());
914 }
915 if let Some(label) = &action.label {
916 member = member.label(label.clone());
917 }
918 if let Some(label) = &action.checked_label {
919 member = member.checked_label(label.clone());
920 }
921 frust::authoring::any::<State, _>(member)
922 }
923
924 fn trigger_view(&self) -> AnyView<State> {
934 let icon = match &self.overflow_icon {
935 Some(factory) => factory(),
936 None => frust::authoring::any::<State, _>(frust::icon(crate::icons::MORE_HORIZ)),
937 };
938 let trigger =
939 crate::toggle_button::toggle_button::<State, _>(false, |_state: &mut State, _v| {})
940 .variant(self.variant)
941 .size(self.size)
942 .haptic(self.haptic)
943 .group_connected(self.group_type == ButtonGroupType::Connected)
944 .first_in_group(false)
945 .last_in_group(true)
946 .icon(icon);
947 match (&self.overflow_anchor, self.overflow) {
948 (Some(anchor), ButtonGroupOverflow::Popup) => {
949 frust::authoring::any::<State, _>(overlay_anchor(anchor, trigger))
950 }
951 _ => frust::authoring::any::<State, _>(trigger),
952 }
953 }
954
955 fn member_views(&self) -> Vec<AnyView<State>> {
957 (0..self.actions.len())
958 .map(|i| self.member_view(i))
959 .collect()
960 }
961
962 fn maybe_trigger_view(&self) -> Option<AnyView<State>> {
964 matches!(
965 self.overflow,
966 ButtonGroupOverflow::BottomSheet | ButtonGroupOverflow::Popup
967 )
968 .then(|| self.trigger_view())
969 }
970
971 pub fn overflow_menu(&self, first_hidden: usize, open: bool) -> AnyView<State> {
1004 let anchor = self.overflow_anchor.clone().unwrap_or_default();
1005 let start = first_hidden.min(self.actions.len());
1006 let nodes: Vec<MenuNode> = self.actions[start..]
1007 .iter()
1008 .enumerate()
1009 .map(|(offset, action)| {
1010 menu_toggleable(action.accessible_label(), self.is_selected(start + offset))
1011 .enabled(action.enabled)
1012 .into()
1013 })
1014 .collect();
1015
1016 let on_indices = self.on_indices_changed.clone();
1017 let on_index = self.on_index_changed.clone();
1018 let on_select = self.on_select.clone();
1019 let current = self.selected_indices.clone().unwrap_or_default();
1020
1021 frust::authoring::any::<State, _>(
1022 menu(nodes, move |state: &mut State, selection: MenuSelection| {
1023 let index = start + selection.index;
1024 let checked = selection.checked().unwrap_or(true);
1025 if let Some(callback) = &on_indices {
1026 let mut next = current.clone();
1027 if checked {
1028 if !next.contains(&index) {
1029 next.push(index);
1030 next.sort_unstable();
1031 }
1032 } else {
1033 next.retain(|i| *i != index);
1034 }
1035 callback(state, next);
1036 } else if let Some(callback) = &on_index {
1037 callback(state, checked.then_some(index));
1038 } else if let Some(callback) = &on_select {
1039 callback(state, index);
1040 }
1041 })
1042 .anchor(&anchor)
1043 .open(open),
1044 )
1045 }
1046}
1047
1048#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1052enum Target {
1053 Member(usize),
1055 Trigger,
1057}
1058
1059pub struct ButtonGroupWidget {
1061 members: Vec<ChildPod>,
1063 trigger: Option<ChildPod>,
1066 labels: Vec<String>,
1068 widths: Vec<Option<f64>>,
1070 group_type: ButtonGroupType,
1071 density: ButtonGroupDensity,
1072 direction: ButtonGroupDirection,
1073 spacing: Option<f64>,
1074 neighbor_squish: bool,
1075 expanded_ratio: f64,
1076 overflow: ButtonGroupOverflow,
1077 semantic_label: Option<String>,
1078 on_overflow: Option<ErasedArgCallback<usize>>,
1079
1080 visible_count: usize,
1084 trigger_visible: bool,
1086 member_bases: Vec<Point>,
1088 trigger_base: Point,
1090 content_main: f64,
1092 viewport_main: f64,
1094
1095 scroll_offset: f64,
1098 active: Option<Target>,
1100 panning: bool,
1102 down_pos: Point,
1104 last_drag: Point,
1106 pressed_index: Option<usize>,
1109 squish: AnimationController,
1111 laid_out_squish: SquishState,
1115}
1116
1117type SquishState = (f64, Option<usize>);
1121
1122impl ButtonGroupWidget {
1123 pub fn supports_squish(&self) -> bool {
1127 self.direction == ButtonGroupDirection::Horizontal
1128 && self.group_type != ButtonGroupType::Connected
1129 && self.neighbor_squish
1130 }
1131
1132 fn squish_state(&self) -> SquishState {
1136 let anim = if self.supports_squish() {
1137 self.squish.value_clamped()
1138 } else {
1139 0.0
1140 };
1141 (anim, self.pressed_index)
1142 }
1143
1144 fn max_scroll(&self) -> f64 {
1146 (self.content_main - self.viewport_main).max(0.0)
1147 }
1148
1149 fn can_scroll(&self) -> bool {
1151 self.overflow == ButtonGroupOverflow::Scroll && self.max_scroll() > FIT_EPSILON
1152 }
1153
1154 fn main_of(&self, p: Point) -> f64 {
1156 match self.direction {
1157 ButtonGroupDirection::Horizontal => p.x,
1158 ButtonGroupDirection::Vertical => p.y,
1159 }
1160 }
1161
1162 fn pod_mut(&mut self, target: Target) -> Option<&mut ChildPod> {
1164 match target {
1165 Target::Member(i) => self.members.get_mut(i),
1166 Target::Trigger => self.trigger.as_mut(),
1167 }
1168 }
1169
1170 fn hit(&self, pos: Point) -> Option<Target> {
1173 if self.trigger_visible
1174 && let Some(trigger) = &self.trigger
1175 && trigger.contains(pos)
1176 {
1177 return Some(Target::Trigger);
1178 }
1179 let visible = self.visible_count.min(self.members.len());
1180 self.members[..visible]
1181 .iter()
1182 .position(|pod| pod.contains(pos))
1183 .map(Target::Member)
1184 }
1185
1186 fn press_in(&mut self) {
1188 self.squish.fling(FLING_VELOCITY, SQUISH_SPRING);
1189 }
1190
1191 fn press_out(&mut self) {
1193 self.squish.fling(-FLING_VELOCITY, SQUISH_SPRING);
1194 }
1195
1196 fn sync_origins(&mut self) {
1199 let horizontal = self.direction == ButtonGroupDirection::Horizontal;
1200 let offset = self.scroll_offset;
1201 let visible = self.visible_count.min(self.members.len());
1202 for (pod, base) in self.members[..visible].iter_mut().zip(&self.member_bases) {
1203 pod.set_origin(shift_main(*base, -offset, horizontal));
1204 }
1205 if self.trigger_visible
1206 && let Some(trigger) = &mut self.trigger
1207 {
1208 trigger.set_origin(shift_main(self.trigger_base, -offset, horizontal));
1209 }
1210 }
1211
1212 fn pan_by(&mut self, delta: f64) -> bool {
1215 let max = self.max_scroll();
1216 let next = (self.scroll_offset - delta).clamp(0.0, max);
1217 if (next - self.scroll_offset).abs() < f64::EPSILON {
1218 return false;
1219 }
1220 self.scroll_offset = next;
1221 self.sync_origins();
1222 true
1223 }
1224
1225 fn send_cancel(&mut self, ctx: &mut EventCtx, target: Target, pos: Point) {
1228 let cancel = InputEvent::Pointer(PointerEvent {
1229 phase: PointerPhase::Cancel,
1230 position: pos,
1231 button: PointerButton::Primary,
1232 });
1233 if let Some(pod) = self.pod_mut(target) {
1234 pod.event_child(ctx, &cancel);
1235 pod.set_active(false);
1236 }
1237 }
1238
1239 fn broadcast(&mut self, ctx: &mut EventCtx, event: &InputEvent) {
1242 for pod in self.members.iter_mut() {
1243 pod.event_child(ctx, event);
1244 }
1245 if let Some(trigger) = &mut self.trigger {
1246 trigger.event_child(ctx, event);
1247 }
1248 }
1249
1250 fn route_focused(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1253 if let Some(pod) = self.members.iter_mut().find(|pod| pod.is_focused()) {
1254 return pod.event_child(ctx, event);
1255 }
1256 if let Some(trigger) = self.trigger.as_mut().filter(|pod| pod.is_focused()) {
1257 return trigger.event_child(ctx, event);
1258 }
1259 EventResult::Ignored
1260 }
1261
1262 fn apply_config<State: 'static>(&mut self, view: &ButtonGroupView<State>) -> ChangeFlags {
1265 let mut flags = ChangeFlags::NONE;
1266 if self.group_type != view.group_type
1267 || self.density != view.density
1268 || self.direction != view.direction
1269 || self.spacing != view.spacing
1270 || self.overflow != view.overflow
1271 || self.neighbor_squish != view.neighbor_squish
1272 || self.expanded_ratio != view.expanded_ratio
1273 {
1274 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1275 }
1276 self.group_type = view.group_type;
1277 self.density = view.density;
1278 self.direction = view.direction;
1279 self.spacing = view.spacing;
1280 self.overflow = view.overflow;
1281 self.neighbor_squish = view.neighbor_squish;
1282 self.expanded_ratio = view.expanded_ratio;
1283 self.semantic_label = view.semantic_label.clone();
1284 self.labels = view
1285 .actions
1286 .iter()
1287 .map(ButtonGroupAction::accessible_label)
1288 .collect();
1289 self.widths = view.actions.iter().map(|a| a.width).collect();
1290 self.on_overflow = view
1291 .on_overflow
1292 .as_ref()
1293 .map(frust::authoring::erase_callback_arg);
1294 flags
1295 }
1296}
1297
1298fn shift_main(base: Point, delta: f64, horizontal: bool) -> Point {
1300 if horizontal {
1301 Point::new(base.x + delta, base.y)
1302 } else {
1303 Point::new(base.x, base.y + delta)
1304 }
1305}
1306
1307impl<State: 'static> View<State> for ButtonGroupView<State> {
1308 type Element = ButtonGroupWidget;
1309
1310 fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonGroupWidget {
1311 let members: Vec<ChildPod> = self
1312 .member_views()
1313 .iter()
1314 .map(|view| frust::authoring::build_child(view, ctx))
1315 .collect();
1316 let trigger = self
1317 .maybe_trigger_view()
1318 .map(|view| frust::authoring::build_child(&view, ctx));
1319 let mut widget = ButtonGroupWidget {
1320 visible_count: members.len(),
1321 members,
1322 trigger,
1323 labels: Vec::new(),
1324 widths: Vec::new(),
1325 group_type: self.group_type,
1326 density: self.density,
1327 direction: self.direction,
1328 spacing: self.spacing,
1329 neighbor_squish: self.neighbor_squish,
1330 expanded_ratio: self.expanded_ratio,
1331 overflow: self.overflow,
1332 semantic_label: None,
1333 on_overflow: None,
1334 trigger_visible: false,
1335 member_bases: Vec::new(),
1336 trigger_base: Point::ZERO,
1337 content_main: 0.0,
1338 viewport_main: 0.0,
1339 scroll_offset: 0.0,
1340 active: None,
1341 panning: false,
1342 down_pos: Point::ZERO,
1343 last_drag: Point::ZERO,
1344 pressed_index: None,
1345 squish: AnimationController::new(SQUISH_ANIM_PERIOD),
1346 laid_out_squish: (0.0, None),
1347 };
1348 widget.apply_config(self);
1349 widget
1350 }
1351
1352 fn rebuild(
1353 &self,
1354 prev: &Self,
1355 element: &mut ButtonGroupWidget,
1356 ctx: &mut BuildCtx<'_>,
1357 ) -> ChangeFlags {
1358 let prev_views = prev.member_views();
1362 let next_views = self.member_views();
1363 let mut flags = frust::authoring::rebuild_children(
1364 &prev_views,
1365 &next_views,
1366 &mut element.members,
1367 ctx,
1368 |view| view,
1369 |_| None,
1370 );
1371
1372 match (prev.maybe_trigger_view(), self.maybe_trigger_view()) {
1373 (Some(prev_view), Some(next_view)) => {
1374 if let Some(pod) = element.trigger.as_mut() {
1375 flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
1376 }
1377 }
1378 (Some(prev_view), None) => {
1379 if let Some(pod) = element.trigger.as_mut() {
1380 frust::authoring::teardown_child(&prev_view, pod, ctx);
1381 }
1382 element.trigger = None;
1383 element.trigger_visible = false;
1384 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1385 }
1386 (None, Some(next_view)) => {
1387 element.trigger = Some(frust::authoring::build_child(&next_view, ctx));
1388 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1389 }
1390 (None, None) => {}
1391 }
1392
1393 if prev.actions.len() != self.actions.len() {
1394 element.active = None;
1396 element.panning = false;
1397 element.pressed_index = None;
1398 element.scroll_offset = 0.0;
1399 element.visible_count = element.members.len();
1400 }
1401 flags |= element.apply_config(self);
1402 flags
1403 }
1404
1405 fn teardown(&self, element: &mut ButtonGroupWidget, ctx: &mut BuildCtx<'_>) {
1406 for (view, pod) in self.member_views().iter().zip(element.members.iter_mut()) {
1407 frust::authoring::teardown_child(view, pod, ctx);
1408 }
1409 if let (Some(view), Some(pod)) = (self.maybe_trigger_view(), element.trigger.as_mut()) {
1410 frust::authoring::teardown_child(&view, pod, ctx);
1411 }
1412 }
1413}
1414
1415impl Widget for ButtonGroupWidget {
1416 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1417 let horizontal = self.direction == ButtonGroupDirection::Horizontal;
1418 let max = bc.max();
1419 let (max_main, max_cross) = if horizontal {
1420 (max.width, max.height)
1421 } else {
1422 (max.height, max.width)
1423 };
1424 self.member_bases.clear();
1425 if self.members.is_empty() {
1426 self.visible_count = 0;
1427 self.trigger_visible = false;
1428 self.content_main = 0.0;
1429 self.viewport_main = 0.0;
1430 return bc.constrain(Size::ZERO);
1431 }
1432
1433 let spacing = resolve_spacing(self.spacing, self.group_type, self.density);
1434
1435 let natural_bc = if horizontal {
1438 BoxConstraints::loose(Size::new(f64::INFINITY, max_cross))
1439 } else {
1440 BoxConstraints::loose(Size::new(max_cross, f64::INFINITY))
1441 };
1442 let mut natural = Vec::with_capacity(self.members.len());
1443 for (i, pod) in self.members.iter_mut().enumerate() {
1444 let size = pod.layout_child(ctx, &natural_bc);
1445 let measured = if horizontal { size.width } else { size.height };
1446 natural.push(self.widths.get(i).copied().flatten().unwrap_or(measured));
1447 }
1448 let trigger_natural = self.trigger.as_mut().map(|pod| {
1449 let size = pod.layout_child(ctx, &natural_bc);
1450 if horizontal { size.width } else { size.height }
1451 });
1452
1453 let (visible_count, trigger_visible) = match (self.overflow, trigger_natural) {
1455 (ButtonGroupOverflow::BottomSheet | ButtonGroupOverflow::Popup, Some(trigger))
1456 if max_main.is_finite() =>
1457 {
1458 let fit = visible_count_for_overflow(max_main, &natural, trigger, spacing);
1459 if fit >= natural.len() {
1460 (natural.len(), false)
1461 } else {
1462 (fit, true)
1463 }
1464 }
1465 _ => (natural.len(), false),
1466 };
1467 self.visible_count = visible_count;
1468 self.trigger_visible = trigger_visible;
1469
1470 let state = self.squish_state();
1474 self.laid_out_squish = state;
1475 let (anim, pressed) = state;
1476 let pressed = pressed.filter(|i| *i < visible_count);
1477 let mut sizes = squish_sizes(
1478 &natural[..visible_count],
1479 pressed,
1480 self.expanded_ratio,
1481 anim,
1482 );
1483 if let Some(trigger) = trigger_natural.filter(|_| trigger_visible) {
1484 sizes.push(trigger);
1485 }
1486
1487 let mut cross = 0.0_f64;
1489 let mut child_sizes = Vec::with_capacity(sizes.len());
1490 {
1491 let pods = self.members[..visible_count]
1492 .iter_mut()
1493 .chain(self.trigger.iter_mut().filter(|_| trigger_visible));
1494 for (pod, main) in pods.zip(sizes.iter()) {
1495 let child_bc = if horizontal {
1496 BoxConstraints::new(Size::new(*main, 0.0), Size::new(*main, max_cross))
1497 } else {
1498 BoxConstraints::new(Size::new(0.0, *main), Size::new(max_cross, *main))
1499 };
1500 let size = pod.layout_child(ctx, &child_bc);
1501 cross = cross.max(if horizontal { size.height } else { size.width });
1502 child_sizes.push(size);
1503 }
1504 }
1505
1506 let laid_out = sizes.len();
1507 let total_main: f64 = sizes.iter().sum::<f64>()
1508 + if laid_out > 0 {
1509 spacing * (laid_out - 1) as f64
1510 } else {
1511 0.0
1512 };
1513
1514 let mut main_offset = 0.0_f64;
1515 let mut bases = Vec::with_capacity(laid_out);
1516 for (main, size) in sizes.iter().zip(child_sizes.iter()) {
1517 let child_cross = if horizontal { size.height } else { size.width };
1518 let cross_offset = ((cross - child_cross) / 2.0).max(0.0);
1519 bases.push(if horizontal {
1520 Point::new(main_offset, cross_offset)
1521 } else {
1522 Point::new(cross_offset, main_offset)
1523 });
1524 main_offset += main + spacing;
1525 }
1526 self.trigger_base = if trigger_visible {
1527 bases.pop().unwrap_or(Point::ZERO)
1528 } else {
1529 Point::ZERO
1530 };
1531 self.member_bases = bases;
1532
1533 let size = bc.constrain(if horizontal {
1534 Size::new(total_main, cross)
1535 } else {
1536 Size::new(cross, total_main)
1537 });
1538 self.content_main = total_main;
1539 self.viewport_main = if horizontal { size.width } else { size.height };
1540 self.scroll_offset = self.scroll_offset.clamp(0.0, self.max_scroll());
1541 self.sync_origins();
1542 size
1543 }
1544
1545 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1546 let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
1547
1548 if reduce_motion {
1549 if self.squish.is_animating() {
1552 self.squish.stop();
1553 }
1554 self.pressed_index = None;
1555 } else {
1556 if self.squish.advance(ctx.frame_time()) {
1560 ctx.request_layout();
1561 } else if self.active.is_none()
1562 && self.squish.value().abs() < SQUISH_REST_EPSILON
1563 && self.pressed_index.is_some()
1564 {
1565 self.pressed_index = None;
1566 }
1567 }
1568 if self.squish_state() != self.laid_out_squish {
1578 ctx.request_layout();
1579 }
1580
1581 let clipped =
1582 self.overflow == ButtonGroupOverflow::Scroll && self.max_scroll() > FIT_EPSILON;
1583 if clipped {
1584 scene.push_clip(ctx.origin(), ctx.size());
1585 }
1586 let visible = self.visible_count.min(self.members.len());
1587 for pod in self.members[..visible].iter_mut() {
1588 pod.paint_child(ctx, scene);
1589 }
1590 if self.trigger_visible
1591 && let Some(trigger) = &mut self.trigger
1592 {
1593 trigger.paint_child(ctx, scene);
1594 }
1595 if clipped {
1596 scene.pop_clip();
1597 }
1598 }
1599
1600 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1601 if event.is_broadcast() {
1602 self.broadcast(ctx, event);
1603 return EventResult::Ignored;
1604 }
1605 if event.is_focus_routed() {
1606 return self.route_focused(ctx, event);
1607 }
1608 match event {
1609 InputEvent::Scroll { delta, .. } => {
1610 if !self.can_scroll() {
1611 return EventResult::Ignored;
1612 }
1613 let (dx, dy) = match delta {
1614 ScrollDelta::Lines(x, y) => (x * WHEEL_LINE_PX, y * WHEEL_LINE_PX),
1615 ScrollDelta::Pixels(x, y) => (*x, *y),
1616 };
1617 let main = match self.direction {
1621 ButtonGroupDirection::Horizontal if dx != 0.0 => dx,
1622 ButtonGroupDirection::Horizontal => dy,
1623 ButtonGroupDirection::Vertical => dy,
1624 };
1625 if self.pan_by(-main) {
1626 ctx.request_redraw();
1627 }
1628 EventResult::Handled
1629 }
1630 InputEvent::Pointer(p) => match p.phase {
1631 PointerPhase::Down => {
1632 let Some(target) = self.hit(p.position) else {
1633 return EventResult::Ignored;
1634 };
1635 if !presses(p) {
1636 return self
1639 .pod_mut(target)
1640 .map(|pod| pod.event_child(ctx, event))
1641 .unwrap_or(EventResult::Ignored);
1642 }
1643 self.active = Some(target);
1644 self.panning = false;
1645 self.down_pos = p.position;
1646 self.last_drag = p.position;
1647 if let Target::Member(i) = target {
1648 self.pressed_index = Some(i);
1649 self.press_in();
1650 }
1651 ctx.capture_pointer();
1652 if let Some(pod) = self.pod_mut(target) {
1653 pod.event_child(ctx, event);
1654 }
1655 ctx.request_redraw();
1656 EventResult::Handled
1657 }
1658 PointerPhase::Move => {
1659 let Some(target) = self.active else {
1660 return match self.hit(p.position) {
1663 Some(hit) => self
1664 .pod_mut(hit)
1665 .map(|pod| pod.event_child(ctx, event))
1666 .unwrap_or(EventResult::Ignored),
1667 None => EventResult::Ignored,
1668 };
1669 };
1670 if self.panning {
1671 let delta = self.main_of(p.position) - self.main_of(self.last_drag);
1672 self.last_drag = p.position;
1673 if self.pan_by(delta) {
1674 ctx.request_redraw();
1675 }
1676 return EventResult::Handled;
1677 }
1678 let travelled = self.main_of(p.position) - self.main_of(self.down_pos);
1679 if self.can_scroll() && travelled.abs() > TOUCH_SLOP {
1680 self.panning = true;
1683 self.last_drag = p.position;
1684 self.send_cancel(ctx, target, p.position);
1685 self.press_out();
1686 ctx.request_redraw();
1687 return EventResult::Handled;
1688 }
1689 if let Some(pod) = self.pod_mut(target) {
1690 pod.event_child(ctx, event);
1691 }
1692 EventResult::Handled
1693 }
1694 PointerPhase::Up => {
1695 let Some(target) = self.active.take() else {
1696 return EventResult::Ignored;
1697 };
1698 let panned = self.panning;
1699 self.panning = false;
1700 if !panned {
1701 if let Some(pod) = self.pod_mut(target) {
1702 pod.event_child(ctx, event);
1703 }
1704 if target == Target::Trigger
1707 && self
1708 .trigger
1709 .as_ref()
1710 .is_some_and(|pod| pod.contains(p.position))
1711 {
1712 let first_hidden = self.visible_count;
1713 if let Some(callback) = self.on_overflow.as_mut() {
1714 callback(ctx, first_hidden);
1715 }
1716 }
1717 }
1718 if let Some(pod) = self.pod_mut(target) {
1719 pod.set_active(false);
1720 }
1721 self.press_out();
1722 ctx.request_redraw();
1723 EventResult::Handled
1724 }
1725 PointerPhase::Cancel => {
1726 let Some(target) = self.active.take() else {
1727 return EventResult::Ignored;
1728 };
1729 if !self.panning
1730 && let Some(pod) = self.pod_mut(target)
1731 {
1732 pod.event_child(ctx, event);
1733 }
1734 if let Some(pod) = self.pod_mut(target) {
1735 pod.set_active(false);
1736 }
1737 self.panning = false;
1738 self.press_out();
1739 ctx.request_redraw();
1740 EventResult::Handled
1741 }
1742 },
1743 _ => EventResult::Ignored,
1744 }
1745 }
1746
1747 fn semantics(&self, ctx: &mut SemanticsCtx) {
1748 let label = self.semantic_label.clone();
1749 ctx.push_container(
1750 Role::Group,
1751 |node| {
1752 if let Some(label) = &label {
1753 node.set_label(label.as_str());
1754 }
1755 },
1756 |ctx| {
1757 let visible = self.visible_count.min(self.members.len());
1758 for pod in &self.members[..visible] {
1759 pod.semantics_child(ctx);
1760 }
1761 if self.trigger_visible
1762 && let Some(trigger) = &self.trigger
1763 {
1764 trigger.semantics_child(ctx);
1765 }
1766 },
1767 );
1768 }
1769
1770 frust::authoring::visit_children!(members, trigger);
1771}
1772
1773#[cfg(test)]
1774mod tests {
1775 use super::*;
1776 use frust::FrameTime;
1777 use frust::authoring::text::TextContext;
1778 use frust_widgets::test_support::leaf_any;
1779 use kurbo::Rect;
1780 use std::any::Any;
1781 use std::cell::RefCell;
1782
1783 const ICON_MEMBER_W: f64 = 36.0;
1790 const MEMBER_H: f64 = 40.0;
1791
1792 fn bare_icon_actions(count: usize) -> Vec<ButtonGroupAction<()>> {
1795 (0..count)
1796 .map(|_| button_group_icon_action::<(), _, _>(|| leaf_any(0.0, 0.0)))
1797 .collect()
1798 }
1799
1800 fn build<S: 'static>(view: &ButtonGroupView<S>) -> ButtonGroupWidget {
1801 let mut counter = 0u64;
1802 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1803 }
1804
1805 fn layout(widget: &mut ButtonGroupWidget, max: Size) -> Size {
1806 let mut tcx = TextContext::new();
1807 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1808 widget.layout(&mut lctx, &BoxConstraints::loose(max))
1809 }
1810
1811 fn layout_themed(widget: &mut ButtonGroupWidget, max: Size, theme: &Theme) -> Size {
1812 let mut tcx = TextContext::new();
1813 let mut lctx =
1814 LayoutCtx::with_text_context(&mut tcx as &mut dyn Any).with_theme(theme as &dyn Any);
1815 widget.layout(&mut lctx, &BoxConstraints::loose(max))
1816 }
1817
1818 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1819 InputEvent::Pointer(PointerEvent {
1820 phase,
1821 position: Point::new(x, y),
1822 button: PointerButton::Primary,
1823 })
1824 }
1825
1826 fn dispatch<S: 'static>(
1827 widget: &mut ButtonGroupWidget,
1828 state: &mut S,
1829 size: Size,
1830 event: &InputEvent,
1831 ) {
1832 let state_any: &mut dyn Any = state;
1833 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1834 widget.event(&mut ctx, event);
1835 }
1836
1837 fn advance_squish(widget: &mut ButtonGroupWidget, secs: f64) {
1839 widget.squish.advance(FrameTime::ZERO);
1840 widget
1841 .squish
1842 .advance(FrameTime::from_nanos((secs * 1e9) as u64));
1843 }
1844
1845 #[derive(Default)]
1846 struct NullScene;
1847
1848 impl PaintScene for NullScene {
1849 fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
1850 fn draw_text(&mut self, _o: Point, _t: &str) {}
1851 }
1852
1853 fn paint(widget: &mut ButtonGroupWidget, size: Size, theme: Option<&Theme>) {
1854 let mut scene = NullScene;
1855 let mut ctx = PaintCtx::new(Point::ZERO, size);
1856 if let Some(theme) = theme {
1857 ctx = ctx.with_theme(theme as &dyn Any);
1858 }
1859 widget.paint(&mut ctx, &mut scene);
1860 }
1861
1862 #[test]
1865 fn single_member_group_is_all_outer_corners() {
1866 let r = member_radii(0, 1, OUTER_RADIUS, INNER_RADIUS);
1867 assert_eq!(r.top_left, 16.0);
1868 assert_eq!(r.top_right, 16.0);
1869 assert_eq!(r.bottom_right, 16.0);
1870 assert_eq!(r.bottom_left, 16.0);
1871 }
1872
1873 #[test]
1874 fn end_members_get_outer_on_their_outer_side_only() {
1875 let first = member_radii(0, 3, 16.0, 4.0);
1876 assert_eq!((first.top_left, first.bottom_left), (16.0, 16.0));
1877 assert_eq!((first.top_right, first.bottom_right), (4.0, 4.0));
1878 let last = member_radii(2, 3, 16.0, 4.0);
1879 assert_eq!((last.top_left, last.bottom_left), (4.0, 4.0));
1880 assert_eq!((last.top_right, last.bottom_right), (16.0, 16.0));
1881 }
1882
1883 #[test]
1884 fn middle_members_are_all_inner_corners() {
1885 let mid = member_radii(1, 3, 16.0, 4.0);
1886 assert_eq!(mid.top_left, 4.0);
1887 assert_eq!(mid.top_right, 4.0);
1888 assert_eq!(mid.bottom_right, 4.0);
1889 assert_eq!(mid.bottom_left, 4.0);
1890 }
1891
1892 #[test]
1895 fn resolved_gap_pins_every_type_density_pair() {
1896 use ButtonGroupDensity::{Compact, Regular};
1897 use ButtonGroupType::{Connected, Standard};
1898 assert_eq!(resolve_spacing(None, Standard, Regular), 8.0);
1899 assert_eq!(resolve_spacing(None, Standard, Compact), 6.0);
1901 assert_eq!(resolve_spacing(None, Connected, Regular), 2.0);
1904 assert_eq!(resolve_spacing(None, Connected, Compact), 2.0);
1905 assert_eq!(resolve_spacing(Some(3.5), Connected, Compact), 3.5);
1907 }
1908
1909 #[test]
1912 fn squish_middle_splits_the_growth_between_both_neighbours() {
1913 let natural = [100.0, 200.0, 100.0];
1914 let sizes = squish_sizes(&natural, Some(1), 0.15, 1.0);
1915 assert_eq!(sizes, vec![85.0, 230.0, 85.0]);
1917 }
1918
1919 #[test]
1920 fn squish_at_the_first_index_moves_all_growth_to_its_only_neighbour() {
1921 let sizes = squish_sizes(&[100.0, 100.0, 100.0], Some(0), 0.15, 1.0);
1922 assert_eq!(sizes, vec![115.0, 85.0, 100.0]);
1923 }
1924
1925 #[test]
1926 fn squish_at_the_last_index_mirrors_the_first() {
1927 let sizes = squish_sizes(&[100.0, 100.0, 100.0], Some(2), 0.15, 1.0);
1928 assert_eq!(sizes, vec![100.0, 85.0, 115.0]);
1929 }
1930
1931 #[test]
1932 fn squish_preserves_the_total_main_extent() {
1933 for pressed in 0..3 {
1934 let natural = [80.0, 120.0, 60.0];
1935 let sizes = squish_sizes(&natural, Some(pressed), 0.15, 0.6);
1936 let before: f64 = natural.iter().sum();
1937 let after: f64 = sizes.iter().sum();
1938 assert!(
1939 (before - after).abs() < 1e-9,
1940 "pressing {pressed} changed the total: {before} -> {after}"
1941 );
1942 }
1943 }
1944
1945 #[test]
1946 fn squish_scales_with_the_spring_progress_and_the_ratio() {
1947 let half = squish_sizes(&[100.0, 100.0], Some(0), 0.15, 0.5);
1948 assert_eq!(half, vec![107.5, 92.5]);
1949 let wider = squish_sizes(&[100.0, 100.0], Some(0), 0.30, 1.0);
1950 assert_eq!(wider, vec![130.0, 70.0]);
1951 }
1952
1953 #[test]
1954 fn squish_is_inert_without_a_press_or_progress() {
1955 let natural = [100.0, 100.0];
1956 assert_eq!(squish_sizes(&natural, None, 0.15, 1.0), natural.to_vec());
1957 assert_eq!(squish_sizes(&natural, Some(0), 0.15, 0.0), natural.to_vec());
1958 assert_eq!(squish_sizes(&natural, Some(9), 0.15, 1.0), natural.to_vec());
1960 }
1961
1962 #[test]
1963 fn squish_of_a_one_member_group_grows_with_nothing_to_absorb_it() {
1964 assert_eq!(squish_sizes(&[100.0], Some(0), 0.15, 1.0), vec![115.0]);
1965 }
1966
1967 #[test]
1968 fn squish_shrink_is_floored_at_zero() {
1969 let sizes = squish_sizes(&[400.0, 10.0], Some(0), 0.15, 1.0);
1971 assert_eq!(sizes, vec![460.0, 0.0]);
1972 }
1973
1974 #[test]
1975 fn squish_applies_only_to_a_horizontal_unconnected_group() {
1976 let cases = [
1977 (
1978 ButtonGroupDirection::Horizontal,
1979 ButtonGroupType::Standard,
1980 true,
1981 true,
1982 ),
1983 (
1984 ButtonGroupDirection::Horizontal,
1985 ButtonGroupType::Connected,
1986 true,
1987 false,
1988 ),
1989 (
1990 ButtonGroupDirection::Vertical,
1991 ButtonGroupType::Standard,
1992 true,
1993 false,
1994 ),
1995 (
1996 ButtonGroupDirection::Horizontal,
1997 ButtonGroupType::Standard,
1998 false,
1999 false,
2000 ),
2001 ];
2002 for (direction, group_type, squish, expected) in cases {
2003 let view = button_group_actions(bare_icon_actions(3))
2004 .direction(direction)
2005 .group_type(group_type)
2006 .neighbor_squish(squish);
2007 let widget = build(&view);
2008 assert_eq!(
2009 widget.supports_squish(),
2010 expected,
2011 "{direction:?}/{group_type:?}/squish={squish}"
2012 );
2013 }
2014 }
2015
2016 #[test]
2019 fn visible_count_is_every_item_when_they_all_fit() {
2020 assert_eq!(visible_count_for_overflow(200.0, &[36.0; 3], 36.0, 8.0), 3);
2022 }
2023
2024 #[test]
2025 fn visible_count_reserves_the_trigger_and_its_separator() {
2026 assert_eq!(visible_count_for_overflow(100.0, &[36.0; 4], 36.0, 8.0), 1);
2029 }
2030
2031 #[test]
2032 fn visible_count_rounds_consumed_up_and_available_down() {
2033 assert_eq!(
2037 visible_count_for_overflow(99.9, &[36.2, 36.2, 36.2], 36.0, 8.0),
2038 1
2039 );
2040 }
2041
2042 #[test]
2043 fn visible_count_is_zero_when_even_one_item_cannot_fit() {
2044 assert_eq!(visible_count_for_overflow(40.0, &[36.0; 3], 36.0, 8.0), 0);
2045 }
2046
2047 #[test]
2050 fn build_creates_one_pod_per_action() {
2051 let view = button_group::<u32, _>(vec!["Day", "Week", "Month"], 0, |s: &mut u32, i| {
2052 *s = i as u32
2053 });
2054 let widget = build(&view);
2055 assert_eq!(widget.members.len(), 3);
2056 assert_eq!(widget.labels, vec!["Day", "Week", "Month"]);
2057 assert!(widget.trigger.is_none(), "no trigger without BottomSheet");
2058 }
2059
2060 #[test]
2061 fn the_legacy_constructor_stays_connected_while_the_action_one_is_standard() {
2062 let legacy = build(&button_group::<(), _>(vec!["A", "B"], 0, |_s, _i| {}));
2063 assert_eq!(legacy.group_type, ButtonGroupType::Connected);
2064 let ported = build(&button_group_actions(bare_icon_actions(2)));
2065 assert_eq!(ported.group_type, ButtonGroupType::Standard);
2066 assert_eq!(ported.overflow, ButtonGroupOverflow::Scroll);
2067 }
2068
2069 #[test]
2070 fn a_bottom_sheet_group_builds_a_trigger_pod() {
2071 let view =
2072 button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::BottomSheet);
2073 let widget = build(&view);
2074 assert!(widget.trigger.is_some());
2075 }
2076
2077 #[test]
2080 fn icon_closures_may_return_a_concrete_view_or_an_any_view() {
2081 let concrete = button_group_actions(vec![
2082 button_group_icon_action::<(), _, _>(|| frust::icon(crate::icons::CHECK))
2083 .checked_icon(|| frust::icon(crate::icons::CHECK)),
2084 ])
2085 .overflow_icon(|| frust::icon(crate::icons::MORE_HORIZ));
2086 let erased = button_group_actions(vec![
2087 button_group_icon_action::<(), _, _>(|| {
2088 frust::authoring::any::<(), _>(frust::icon(crate::icons::CHECK))
2089 })
2090 .checked_icon(|| frust::authoring::any::<(), _>(frust::icon(crate::icons::CHECK))),
2091 ])
2092 .overflow_icon(|| frust::authoring::any::<(), _>(frust::icon(crate::icons::MORE_HORIZ)));
2093 let mut a = build(&concrete);
2094 let mut b = build(&erased);
2095 assert_eq!(a.members.len(), b.members.len());
2096 let max = Size::new(500.0, 200.0);
2097 assert_eq!(layout(&mut a, max), layout(&mut b, max));
2098 }
2099
2100 #[test]
2101 fn members_abut_with_the_resolved_gap() {
2102 let mut widget = build(&button_group_actions(bare_icon_actions(3)));
2103 let size = layout(&mut widget, Size::new(500.0, 200.0));
2104 assert_eq!(size, Size::new(3.0 * ICON_MEMBER_W + 16.0, MEMBER_H));
2106 assert_eq!(widget.member_bases[0].x, 0.0);
2107 assert_eq!(widget.member_bases[1].x, ICON_MEMBER_W + 8.0);
2108 assert_eq!(widget.member_bases[2].x, 2.0 * (ICON_MEMBER_W + 8.0));
2109 }
2110
2111 #[test]
2112 fn a_connected_group_uses_the_hairline_seam() {
2113 let mut widget = build(
2114 &button_group_actions(bare_icon_actions(3)).group_type(ButtonGroupType::Connected),
2115 );
2116 layout(&mut widget, Size::new(500.0, 200.0));
2117 assert_eq!(widget.member_bases[1].x, ICON_MEMBER_W + CONNECTED_GAP);
2118 }
2119
2120 #[test]
2121 fn a_vertical_group_stacks_along_the_cross_axis() {
2122 let mut widget = build(
2123 &button_group_actions(bare_icon_actions(2)).direction(ButtonGroupDirection::Vertical),
2124 );
2125 let size = layout(&mut widget, Size::new(200.0, 500.0));
2126 assert_eq!(size, Size::new(ICON_MEMBER_W, 2.0 * MEMBER_H + 8.0));
2127 assert_eq!(widget.member_bases[1].y, MEMBER_H + 8.0);
2128 }
2129
2130 #[test]
2131 fn a_pinned_action_width_overrides_the_natural_extent() {
2132 let actions = vec![
2133 button_group_icon_action::<(), _, _>(|| leaf_any(0.0, 0.0)).width(90.0),
2134 button_group_icon_action::<(), _, _>(|| leaf_any(0.0, 0.0)),
2135 ];
2136 let mut widget = build(&button_group_actions(actions));
2137 let size = layout(&mut widget, Size::new(500.0, 200.0));
2138 assert_eq!(size.width, 90.0 + 8.0 + ICON_MEMBER_W);
2139 assert_eq!(widget.member_bases[1].x, 98.0);
2140 }
2141
2142 #[test]
2143 fn pressing_a_member_squishes_its_neighbour_in_layout() {
2144 let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2145 let unpressed = layout(&mut widget, Size::new(500.0, 200.0));
2146 let gap_start = widget.member_bases[1].x;
2147
2148 let mut state = ();
2149 dispatch(
2150 &mut widget,
2151 &mut state,
2152 unpressed,
2153 &ev(PointerPhase::Down, 4.0, 20.0),
2154 );
2155 advance_squish(&mut widget, 0.05);
2156 let pressed = layout(&mut widget, Size::new(500.0, 200.0));
2157
2158 assert!(
2159 widget.member_bases[1].x > gap_start,
2160 "member 0 grew, pushing member 1 along ({} -> {})",
2161 gap_start,
2162 widget.member_bases[1].x
2163 );
2164 assert!(
2165 (pressed.width - unpressed.width).abs() < 1e-6,
2166 "the squish is extent-preserving: {unpressed:?} -> {pressed:?}"
2167 );
2168 }
2169
2170 fn labelled_actions() -> Vec<ButtonGroupAction<()>> {
2175 vec![
2176 button_group_action("Every day"),
2177 button_group_action("Days per week"),
2178 button_group_action("Selected days"),
2179 ]
2180 }
2181
2182 #[derive(Default)]
2186 struct GlyphCounter {
2187 runs: Vec<usize>,
2188 }
2189
2190 impl PaintScene for GlyphCounter {
2191 fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
2192 fn draw_text(&mut self, _o: Point, _t: &str) {}
2193 fn draw_glyph_run(&mut self, run: frust::authoring::scene::GlyphRun) {
2194 self.runs.push(run.glyphs.len());
2195 }
2196 }
2197
2198 struct Frames {
2204 widget: ButtonGroupWidget,
2205 text: TextContext,
2206 theme: Theme,
2207 max: Size,
2208 size: Size,
2209 needs_layout: bool,
2210 }
2211
2212 impl Frames {
2213 fn new(view: &ButtonGroupView<()>, max: Size) -> Self {
2214 Frames {
2215 widget: build(view),
2216 text: TextContext::new(),
2217 theme: crate::baseline(),
2218 max,
2219 size: Size::ZERO,
2220 needs_layout: true,
2221 }
2222 }
2223
2224 fn run(&mut self, t_ms: u64) -> Vec<usize> {
2227 if self.needs_layout {
2228 let mut lctx = LayoutCtx::with_text_context(&mut self.text as &mut dyn Any)
2229 .with_theme(&self.theme as &dyn Any);
2230 self.size = self
2231 .widget
2232 .layout(&mut lctx, &BoxConstraints::loose(self.max));
2233 }
2234 let mut scene = GlyphCounter::default();
2235 let mut ctx = PaintCtx::for_test(
2236 Point::ZERO,
2237 self.size,
2238 FrameTime::from_nanos(t_ms * 1_000_000),
2239 )
2240 .with_theme(&self.theme as &dyn Any);
2241 self.widget.paint(&mut ctx, &mut scene);
2242 self.needs_layout = ctx.needs_layout();
2243 scene.runs
2244 }
2245
2246 fn run_until(&mut self, from_ms: u64, to_ms: u64) -> Vec<usize> {
2248 let mut painted = Vec::new();
2249 let mut t = from_ms;
2250 while t <= to_ms {
2251 painted = self.run(t);
2252 t += 16;
2253 }
2254 painted
2255 }
2256
2257 fn member_mains(&self) -> Vec<f64> {
2259 self.widget.members.iter().map(|p| p.size().width).collect()
2260 }
2261
2262 fn pointer(&mut self, phase: PointerPhase, x: f64) {
2263 let mut state = ();
2264 let size = self.size;
2265 dispatch(&mut self.widget, &mut state, size, &ev(phase, x, 16.0));
2266 }
2267 }
2268
2269 #[test]
2270 fn a_settled_squish_restores_every_member_to_its_natural_shape() {
2271 let view = button_group_actions(labelled_actions())
2272 .density(ButtonGroupDensity::Compact)
2273 .spacing(8.0)
2274 .size(ToggleButtonSize::Sm);
2275 let mut frames = Frames::new(&view, Size::new(1000.0, 200.0));
2276 let resting_runs = frames.run(0);
2277 let resting_mains = frames.member_mains();
2278 assert_eq!(resting_runs.len(), 3, "one label run per member");
2279
2280 let mut t = 0u64;
2284 for pressed in [1usize, 0] {
2285 let x = frames.widget.member_bases[pressed].x + 4.0;
2286 frames.pointer(PointerPhase::Down, x);
2287 let mid_squish = frames.run_until(t + 16, t + 16 * 4);
2288 assert_eq!(
2289 mid_squish, resting_runs,
2290 "a squished neighbour of {pressed} covers its label, never re-shapes it"
2291 );
2292 frames.run_until(t + 16 * 5, t + 16 * 60);
2293 frames.pointer(PointerPhase::Up, x);
2294 let settled_runs = frames.run_until(t + 16 * 61, t + 16 * 160);
2295 t += 16 * 160;
2296
2297 assert!(
2298 !frames.widget.squish.is_animating(),
2299 "the squish spring settled within the driven frames (pressed {pressed})"
2300 );
2301 assert_eq!(
2302 frames.widget.pressed_index, None,
2303 "the settled spring released member {pressed}"
2304 );
2305 assert_eq!(
2306 settled_runs, resting_runs,
2307 "every label paints its full glyph run again once member {pressed}'s squish settles"
2308 );
2309 assert_eq!(
2310 frames.member_mains(),
2311 resting_mains,
2312 "every member is back at its natural main extent after pressing {pressed}"
2313 );
2314 }
2315 }
2316
2317 #[test]
2318 fn reduce_motion_freezes_the_squish() {
2319 let mut theme = crate::baseline();
2320 theme.motion.reduce_motion = true;
2321 let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2322 let size = layout_themed(&mut widget, Size::new(500.0, 200.0), &theme);
2323 let mut state = ();
2324 dispatch(
2325 &mut widget,
2326 &mut state,
2327 size,
2328 &ev(PointerPhase::Down, 4.0, 20.0),
2329 );
2330 assert!(widget.squish.is_animating());
2331 paint(&mut widget, size, Some(&theme));
2332 assert!(
2333 !widget.squish.is_animating(),
2334 "reduce_motion stops the spring"
2335 );
2336 assert_eq!(widget.pressed_index, None);
2337 }
2338
2339 #[test]
2342 fn tap_reports_the_tapped_member_index() {
2343 let mut widget = build(&button_group::<u32, _>(
2344 vec!["A", "B", "C"],
2345 0,
2346 |s: &mut u32, i| *s = i as u32,
2347 ));
2348 let size = layout(&mut widget, Size::new(500.0, 200.0));
2349 let mid = widget.member_bases[1].x + 4.0;
2350 let mut state = 0u32;
2351 dispatch(
2352 &mut widget,
2353 &mut state,
2354 size,
2355 &ev(PointerPhase::Down, mid, 20.0),
2356 );
2357 assert_eq!(widget.pressed_index, Some(1));
2358 dispatch(
2359 &mut widget,
2360 &mut state,
2361 size,
2362 &ev(PointerPhase::Up, mid, 20.0),
2363 );
2364 assert_eq!(state, 1, "on_select fired with the tapped index");
2365 }
2366
2367 #[test]
2368 fn the_legacy_callback_still_fires_on_the_already_selected_member() {
2369 let mut widget = build(&button_group::<u32, _>(
2372 vec!["A", "B"],
2373 1,
2374 |s: &mut u32, i| *s = 40 + i as u32,
2375 ));
2376 let size = layout(&mut widget, Size::new(500.0, 200.0));
2377 let x = widget.member_bases[1].x + 4.0;
2378 let mut state = 0u32;
2379 dispatch(
2380 &mut widget,
2381 &mut state,
2382 size,
2383 &ev(PointerPhase::Down, x, 20.0),
2384 );
2385 dispatch(
2386 &mut widget,
2387 &mut state,
2388 size,
2389 &ev(PointerPhase::Up, x, 20.0),
2390 );
2391 assert_eq!(state, 41);
2392 }
2393
2394 #[test]
2395 fn single_select_reports_none_when_deselecting_the_current_member() {
2396 let seen: Rc<RefCell<Vec<Option<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2397 let sink = seen.clone();
2398 let view = button_group_actions(bare_icon_actions(2))
2399 .selected_index(Some(1))
2400 .on_selected_index_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2401 let mut widget = build(&view);
2402 let size = layout(&mut widget, Size::new(500.0, 200.0));
2403 let x = widget.member_bases[1].x + 4.0;
2404 let mut state = ();
2405 dispatch(
2406 &mut widget,
2407 &mut state,
2408 size,
2409 &ev(PointerPhase::Down, x, 20.0),
2410 );
2411 dispatch(
2412 &mut widget,
2413 &mut state,
2414 size,
2415 &ev(PointerPhase::Up, x, 20.0),
2416 );
2417 assert_eq!(*seen.borrow(), vec![None]);
2418 }
2419
2420 #[test]
2421 fn multi_select_reports_the_requested_index_set() {
2422 let seen: Rc<RefCell<Vec<Vec<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2423 let sink = seen.clone();
2424 let view = button_group_actions(bare_icon_actions(3))
2425 .selected_indices([2])
2426 .on_selected_indices_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2427 let mut widget = build(&view);
2428 let size = layout(&mut widget, Size::new(500.0, 200.0));
2429 let x = widget.member_bases[0].x + 4.0;
2430 let mut state = ();
2431 dispatch(
2432 &mut widget,
2433 &mut state,
2434 size,
2435 &ev(PointerPhase::Down, x, 20.0),
2436 );
2437 dispatch(
2438 &mut widget,
2439 &mut state,
2440 size,
2441 &ev(PointerPhase::Up, x, 20.0),
2442 );
2443 assert_eq!(*seen.borrow(), vec![vec![0, 2]], "added, kept ascending");
2444 }
2445
2446 #[test]
2447 fn multi_select_removes_an_already_selected_index() {
2448 let seen: Rc<RefCell<Vec<Vec<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2449 let sink = seen.clone();
2450 let view = button_group_actions(bare_icon_actions(3))
2451 .selected_indices([0, 2])
2452 .on_selected_indices_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2453 let mut widget = build(&view);
2454 let size = layout(&mut widget, Size::new(500.0, 200.0));
2455 let x = widget.member_bases[2].x + 4.0;
2456 let mut state = ();
2457 dispatch(
2458 &mut widget,
2459 &mut state,
2460 size,
2461 &ev(PointerPhase::Down, x, 20.0),
2462 );
2463 dispatch(
2464 &mut widget,
2465 &mut state,
2466 size,
2467 &ev(PointerPhase::Up, x, 20.0),
2468 );
2469 assert_eq!(*seen.borrow(), vec![vec![0]]);
2470 }
2471
2472 #[test]
2473 fn selection_resolution_follows_the_documented_precedence() {
2474 let view = button_group_actions(bare_icon_actions(3))
2476 .selected_index(Some(0))
2477 .selected_indices([1, 2]);
2478 assert!(!view.is_selected(0));
2479 assert!(view.is_selected(1) && view.is_selected(2));
2480
2481 let actions = vec![
2483 button_group_icon_action::<(), _, _>(|| leaf_any(0.0, 0.0)).checked(true),
2484 button_group_icon_action::<(), _, _>(|| leaf_any(0.0, 0.0)),
2485 ];
2486 let view = button_group_actions(actions).selected_index(Some(1));
2487 assert!(!view.is_selected(0));
2488 assert!(view.is_selected(1));
2489
2490 let actions = vec![
2492 button_group_icon_action::<(), _, _>(|| leaf_any(0.0, 0.0)).checked(true),
2493 button_group_icon_action::<(), _, _>(|| leaf_any(0.0, 0.0)),
2494 ];
2495 let view = button_group_actions(actions);
2496 assert!(view.is_selected(0));
2497 assert!(!view.is_selected(1));
2498 }
2499
2500 #[test]
2501 fn a_selected_index_outside_the_action_range_selects_nothing() {
2502 let view = button_group::<(), _>(vec!["A", "B"], 7, |_s, _i| {});
2503 assert!(!view.is_selected(0));
2504 assert!(!view.is_selected(1));
2505 }
2506
2507 #[test]
2508 fn is_controlled_the_view_never_self_mutates_its_selection() {
2509 let mut widget = build(&button_group::<u32, _>(vec!["A", "B"], 0, |_s, _i| {}));
2513 let size = layout(&mut widget, Size::new(500.0, 200.0));
2514 let x = widget.member_bases[1].x + 4.0;
2515 let mut state = 0u32;
2516 dispatch(
2517 &mut widget,
2518 &mut state,
2519 size,
2520 &ev(PointerPhase::Down, x, 20.0),
2521 );
2522 dispatch(
2523 &mut widget,
2524 &mut state,
2525 size,
2526 &ev(PointerPhase::Up, x, 20.0),
2527 );
2528
2529 let prev = button_group::<u32, _>(vec!["A", "B"], 0, |_s, _i| {});
2530 let next = button_group::<u32, _>(vec!["A", "B"], 1, |_s, _i| {});
2531 let mut counter = 0u64;
2532 let flags =
2533 View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
2534 assert!(!flags.is_empty(), "the selection flip reaches the members");
2535 }
2536
2537 #[test]
2538 fn a_release_outside_the_pressed_member_does_not_fire() {
2539 let mut widget = build(&button_group::<u32, _>(
2540 vec!["A", "B"],
2541 0,
2542 |s: &mut u32, i| *s = i as u32,
2543 ));
2544 let size = layout(&mut widget, Size::new(500.0, 200.0));
2545 let mut state = 7u32;
2546 dispatch(
2547 &mut widget,
2548 &mut state,
2549 size,
2550 &ev(PointerPhase::Down, 4.0, 20.0),
2551 );
2552 let away = widget.member_bases[1].x + 4.0;
2553 dispatch(
2554 &mut widget,
2555 &mut state,
2556 size,
2557 &ev(PointerPhase::Move, away, 20.0),
2558 );
2559 dispatch(
2560 &mut widget,
2561 &mut state,
2562 size,
2563 &ev(PointerPhase::Up, away, 20.0),
2564 );
2565 assert_eq!(state, 7, "the member's own up-inside rule still governs");
2566 }
2567
2568 #[test]
2569 fn a_down_outside_every_member_is_ignored() {
2570 let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2571 let size = layout(&mut widget, Size::new(500.0, 200.0));
2572 let mut state = ();
2573 let state_any: &mut dyn Any = &mut state;
2574 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2575 let result = widget.event(&mut ctx, &ev(PointerPhase::Down, 400.0, 20.0));
2576 assert!(matches!(result, EventResult::Ignored));
2577 assert_eq!(widget.pressed_index, None);
2578 }
2579
2580 #[test]
2581 fn cancel_clears_the_press_without_firing() {
2582 let mut widget = build(&button_group::<u32, _>(
2583 vec!["A", "B"],
2584 0,
2585 |s: &mut u32, i| *s = i as u32,
2586 ));
2587 let size = layout(&mut widget, Size::new(500.0, 200.0));
2588 let mut state = 3u32;
2589 dispatch(
2590 &mut widget,
2591 &mut state,
2592 size,
2593 &ev(PointerPhase::Down, 4.0, 20.0),
2594 );
2595 assert!(widget.active.is_some());
2596 dispatch(
2597 &mut widget,
2598 &mut state,
2599 size,
2600 &ev(PointerPhase::Cancel, 4.0, 20.0),
2601 );
2602 assert!(widget.active.is_none());
2603 assert_eq!(state, 3);
2604 }
2605
2606 #[test]
2609 fn overflow_none_lets_the_content_exceed_the_box() {
2610 let mut widget =
2611 build(&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::None));
2612 layout(&mut widget, Size::new(100.0, 200.0));
2613 assert_eq!(widget.visible_count, 6, "nothing is hidden");
2614 assert!(widget.content_main > widget.viewport_main);
2615 assert!(!widget.can_scroll(), "None never pans");
2616 }
2617
2618 #[test]
2619 fn overflow_scroll_pans_after_the_touch_slop() {
2620 let mut widget = build(&button_group_actions(bare_icon_actions(6)));
2621 let size = layout(&mut widget, Size::new(100.0, 200.0));
2622 assert!(widget.can_scroll());
2623 let start_x = widget.member_bases[0].x;
2624 let mut state = ();
2625 dispatch(
2626 &mut widget,
2627 &mut state,
2628 size,
2629 &ev(PointerPhase::Down, 4.0, 20.0),
2630 );
2631 dispatch(
2633 &mut widget,
2634 &mut state,
2635 size,
2636 &ev(PointerPhase::Move, 4.0, 20.0),
2637 );
2638 assert!(!widget.panning);
2639 assert_eq!(widget.scroll_offset, 0.0);
2640 let far = 4.0 - TOUCH_SLOP - 20.0;
2642 dispatch(
2643 &mut widget,
2644 &mut state,
2645 size,
2646 &ev(PointerPhase::Move, far, 20.0),
2647 );
2648 assert!(widget.panning);
2649 dispatch(
2650 &mut widget,
2651 &mut state,
2652 size,
2653 &ev(PointerPhase::Move, far - 15.0, 20.0),
2654 );
2655 assert!(widget.scroll_offset > 0.0, "the row panned");
2656 assert!(
2657 widget.members[0].origin().x < start_x,
2658 "member origins follow the pan"
2659 );
2660 dispatch(
2661 &mut widget,
2662 &mut state,
2663 size,
2664 &ev(PointerPhase::Up, far - 15.0, 20.0),
2665 );
2666 assert!(!widget.panning);
2667 }
2668
2669 #[test]
2670 fn overflow_scroll_clamps_the_pan_to_the_content() {
2671 let mut widget = build(&button_group_actions(bare_icon_actions(6)));
2672 layout(&mut widget, Size::new(100.0, 200.0));
2673 let max = widget.max_scroll();
2674 widget.pan_by(-10_000.0);
2675 assert_eq!(widget.scroll_offset, max);
2676 widget.pan_by(10_000.0);
2677 assert_eq!(widget.scroll_offset, 0.0);
2678 }
2679
2680 #[test]
2681 fn overflow_scroll_does_not_pan_when_the_content_fits() {
2682 let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2683 layout(&mut widget, Size::new(500.0, 200.0));
2684 assert!(!widget.can_scroll());
2685 assert!(!widget.pan_by(-50.0));
2686 }
2687
2688 #[test]
2689 fn overflow_bottom_sheet_splits_and_shows_a_trigger() {
2690 let mut widget = build(
2691 &button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::BottomSheet),
2692 );
2693 layout(&mut widget, Size::new(140.0, 200.0));
2694 assert_eq!(widget.visible_count, 2);
2698 assert!(widget.trigger_visible);
2699 assert_eq!(widget.member_bases.len(), 2);
2700 }
2701
2702 #[test]
2703 fn overflow_bottom_sheet_hides_nothing_when_everything_fits() {
2704 let mut widget = build(
2705 &button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::BottomSheet),
2706 );
2707 layout(&mut widget, Size::new(500.0, 200.0));
2708 assert_eq!(widget.visible_count, 3);
2709 assert!(!widget.trigger_visible);
2710 }
2711
2712 #[test]
2713 fn tapping_the_overflow_trigger_reports_the_first_hidden_index() {
2714 let seen: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
2715 let sink = seen.clone();
2716 let view = button_group_actions(bare_icon_actions(6))
2717 .overflow(ButtonGroupOverflow::BottomSheet)
2718 .on_overflow(move |_s: &mut (), first| sink.borrow_mut().push(first));
2719 let mut widget = build(&view);
2720 let size = layout(&mut widget, Size::new(140.0, 200.0));
2721 let trigger_x = widget.trigger_base.x + 4.0;
2722 let mut state = ();
2723 dispatch(
2724 &mut widget,
2725 &mut state,
2726 size,
2727 &ev(PointerPhase::Down, trigger_x, 20.0),
2728 );
2729 assert_eq!(widget.active, Some(Target::Trigger));
2730 assert_eq!(widget.pressed_index, None, "the trigger never squishes");
2731 dispatch(
2732 &mut widget,
2733 &mut state,
2734 size,
2735 &ev(PointerPhase::Up, trigger_x, 20.0),
2736 );
2737 assert_eq!(*seen.borrow(), vec![2]);
2738 }
2739
2740 #[test]
2743 fn overflow_popup_group_builds_a_trigger_pod() {
2744 let view = button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::Popup);
2745 let widget = build(&view);
2746 assert!(widget.trigger.is_some());
2747 }
2748
2749 #[test]
2750 fn overflow_popup_splits_and_shows_a_trigger() {
2751 let mut widget =
2755 build(&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::Popup));
2756 layout(&mut widget, Size::new(140.0, 200.0));
2757 assert_eq!(widget.visible_count, 2);
2758 assert!(widget.trigger_visible);
2759 }
2760
2761 #[test]
2762 fn the_trigger_captures_its_window_rect_into_the_overflow_anchor_when_wired() {
2763 let anchor = OverlayAnchor::new();
2764 let cell = anchor.clone();
2765 let mut root: frust_core::RenderRoot<(), ButtonGroupView<()>> =
2766 frust_core::RenderRoot::new();
2767 let mut state = ();
2768 let mut logic = move |_s: &mut ()| {
2769 button_group_actions(bare_icon_actions(6))
2770 .overflow(ButtonGroupOverflow::Popup)
2771 .overflow_anchor(&cell)
2772 };
2773 root.rebuild(&mut logic, &mut state);
2774 let mut tcx = TextContext::new();
2775 root.layout_with_text(Size::new(140.0, 200.0), &mut tcx as &mut dyn Any);
2776 let mut scene = NullScene;
2777 root.paint(&mut scene, FrameTime::ZERO);
2778 let rect = anchor.rect();
2779 assert!(
2780 rect.width() > 0.0 && rect.height() > 0.0,
2781 "the trigger's window rect was captured into the overflow anchor on paint: {rect:?}"
2782 );
2783 }
2784
2785 #[derive(Default)]
2786 struct OverflowMenuState {
2787 selected: Option<usize>,
2788 }
2789
2790 fn overflow_menu_actions() -> Vec<ButtonGroupAction<OverflowMenuState>> {
2791 (0..6)
2792 .map(|i| button_group_action(format!("Item {i}")))
2793 .collect()
2794 }
2795
2796 #[test]
2797 fn overflow_menu_shows_hidden_members_checked_state_per_upstream() {
2798 let anchor = OverlayAnchor::new();
2805 anchor.set(Rect::new(40.0, 100.0, 160.0, 140.0));
2806 let cell = anchor.clone();
2807 let mut root: frust_core::RenderRoot<
2808 OverflowMenuState,
2809 frust::StackView<OverflowMenuState>,
2810 > = frust_core::RenderRoot::new();
2811 let mut state = OverflowMenuState { selected: Some(4) };
2812 let mut logic = move |s: &mut OverflowMenuState| {
2813 let group = button_group_actions(overflow_menu_actions())
2814 .overflow(ButtonGroupOverflow::Popup)
2815 .overflow_anchor(&cell)
2816 .selected_index(s.selected);
2817 frust::stack().child(group.overflow_menu(4, true))
2821 };
2822 root.rebuild(&mut logic, &mut state);
2823 let mut tcx = TextContext::new();
2824 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2825 let update = root.semantics();
2826
2827 let boxes: Vec<_> = update
2828 .nodes
2829 .iter()
2830 .filter(|(_, n)| n.role() == Role::MenuItemCheckBox)
2831 .collect();
2832 assert_eq!(boxes.len(), 2, "only the two hidden members (indices 4, 5)");
2833 let checked = boxes
2834 .iter()
2835 .find(|(_, n)| n.label() == Some("Item 4"))
2836 .expect("Item 4 is present");
2837 assert_eq!(
2838 checked.1.toggled(),
2839 Some(frust::authoring::Toggled::True),
2840 "the group's own selected index shows checked in the popup"
2841 );
2842 let unchecked = boxes
2843 .iter()
2844 .find(|(_, n)| n.label() == Some("Item 5"))
2845 .expect("Item 5 is present");
2846 assert_eq!(
2847 unchecked.1.toggled(),
2848 Some(frust::authoring::Toggled::False)
2849 );
2850 }
2851
2852 #[test]
2853 fn overflow_menu_selection_routes_through_the_groups_own_fan_out() {
2854 let anchor_rect = Rect::new(40.0, 100.0, 160.0, 140.0);
2858 let anchor = OverlayAnchor::new();
2859 anchor.set(anchor_rect);
2860 let cell = anchor.clone();
2861 let mut root: frust_core::RenderRoot<
2862 OverflowMenuState,
2863 frust::StackView<OverflowMenuState>,
2864 > = frust_core::RenderRoot::new();
2865 let mut state = OverflowMenuState { selected: None };
2866 let mut logic = move |s: &mut OverflowMenuState| {
2867 let group = button_group_actions(overflow_menu_actions())
2868 .overflow(ButtonGroupOverflow::Popup)
2869 .overflow_anchor(&cell)
2870 .selected_index(s.selected)
2871 .on_selected_index_changed(|s: &mut OverflowMenuState, i| s.selected = i);
2872 frust::stack().child(group.overflow_menu(5, true))
2873 };
2874 root.rebuild(&mut logic, &mut state);
2875 let mut tcx = TextContext::new();
2876 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2877
2878 let row = Point::new(
2879 anchor_rect.x0 + crate::menu::MENU_MIN_WIDTH / 2.0,
2880 anchor_rect.y1
2881 + crate::overlay::OVERLAY_ANCHOR_GAP
2882 + crate::menu::MENU_SURFACE_V_PADDING
2883 + (crate::menu::MENU_ITEM_GAP + crate::menu::MENU_ROW_MIN_HEIGHT) / 2.0,
2884 );
2885 root.event(&mut state, &ev(PointerPhase::Down, row.x, row.y));
2886 root.event(&mut state, &ev(PointerPhase::Up, row.x, row.y));
2887
2888 assert_eq!(
2889 state.selected,
2890 Some(5),
2891 "activating the only hidden member's popup row reports through \
2892 `on_selected_index_changed`, exactly as a visible member's own tap does"
2893 );
2894 }
2895
2896 #[test]
2899 fn semantics_yields_a_group_of_toggle_members() {
2900 fn logic(_s: &mut ()) -> ButtonGroupView<()> {
2904 button_group::<(), _>(vec!["One", "Two", "Three"], 2, |_s: &mut (), _i| {})
2905 .semantic_label("Range")
2906 }
2907 let mut root: frust_core::RenderRoot<(), ButtonGroupView<()>> =
2908 frust_core::RenderRoot::new();
2909 let mut state = ();
2910 root.rebuild(&mut logic, &mut state);
2911 let mut tcx = TextContext::new();
2912 root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
2913 let update = root.semantics();
2914
2915 let group = update
2916 .nodes
2917 .iter()
2918 .find(|(_, n)| n.role() == Role::Group)
2919 .expect("a Group container node is contributed");
2920 assert_eq!(group.1.children().len(), 3);
2921 assert_eq!(group.1.label(), Some("Range"));
2922
2923 let members: Vec<_> = update
2924 .nodes
2925 .iter()
2926 .filter(|(_, n)| n.role() == Role::Button)
2927 .collect();
2928 assert_eq!(members.len(), 3);
2929 let selected = members
2930 .iter()
2931 .find(|(_, n)| n.label() == Some("Three"))
2932 .expect("the Three member is present");
2933 assert_eq!(selected.1.toggled(), Some(frust::authoring::Toggled::True));
2934 let unselected = members
2935 .iter()
2936 .find(|(_, n)| n.label() == Some("One"))
2937 .expect("the One member is present");
2938 assert_eq!(
2939 unselected.1.toggled(),
2940 Some(frust::authoring::Toggled::False)
2941 );
2942 }
2943
2944 #[test]
2945 fn semantics_omit_the_members_the_overflow_split_hid() {
2946 let mut widget = build(
2947 &button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::BottomSheet),
2948 );
2949 layout(&mut widget, Size::new(140.0, 200.0));
2950 assert_eq!(widget.visible_count, 2);
2951 assert!(widget.hit(Point::new(300.0, 20.0)).is_none());
2954 }
2955
2956 #[test]
2959 fn this_module_no_longer_uses_the_legacy_radial_shape_model() {
2960 let src = include_str!("button_group.rs");
2963 let import = format!("use super::{}_morph", "shape");
2964 let call = format!("{}_path(", "morph");
2965 assert!(
2966 !src.contains(&import),
2967 "the legacy radial polygon module must not be imported here"
2968 );
2969 assert!(
2970 !src.contains(&call),
2971 "the legacy radial morph primitive must not be called here"
2972 );
2973 }
2974}