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
471pub struct ButtonGroupAction<State: 'static> {
479 icon: Option<IconFactory<State>>,
480 checked_icon: Option<IconFactory<State>>,
481 label: Option<String>,
482 checked_label: Option<String>,
483 checked: Option<bool>,
484 enabled: bool,
485 width: Option<f64>,
486}
487
488pub fn button_group_action<State: 'static>(label: impl Into<String>) -> ButtonGroupAction<State> {
490 ButtonGroupAction {
491 icon: None,
492 checked_icon: None,
493 label: Some(label.into()),
494 checked_label: None,
495 checked: None,
496 enabled: true,
497 width: None,
498 }
499}
500
501pub fn button_group_icon_action<State: 'static, F>(icon: F) -> ButtonGroupAction<State>
504where
505 F: Fn() -> AnyView<State> + 'static,
506{
507 ButtonGroupAction {
508 icon: Some(Rc::new(icon)),
509 checked_icon: None,
510 label: None,
511 checked_label: None,
512 checked: None,
513 enabled: true,
514 width: None,
515 }
516}
517
518impl<State: 'static> ButtonGroupAction<State> {
519 pub fn icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
522 self.icon = Some(Rc::new(icon));
523 self
524 }
525
526 pub fn checked_icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
528 self.checked_icon = Some(Rc::new(icon));
529 self
530 }
531
532 pub fn label(mut self, label: impl Into<String>) -> Self {
535 self.label = Some(label.into());
536 self
537 }
538
539 pub fn checked_label(mut self, label: impl Into<String>) -> Self {
541 self.checked_label = Some(label.into());
542 self
543 }
544
545 pub fn checked(mut self, checked: bool) -> Self {
549 self.checked = Some(checked);
550 self
551 }
552
553 pub fn enabled(mut self, enabled: bool) -> Self {
555 self.enabled = enabled;
556 self
557 }
558
559 pub fn width(mut self, width: f64) -> Self {
562 self.width = Some(width);
563 self
564 }
565
566 fn accessible_label(&self) -> String {
569 self.checked_label
570 .clone()
571 .or_else(|| self.label.clone())
572 .unwrap_or_default()
573 }
574}
575
576type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
580type OnIndexChanged<State> = Rc<dyn Fn(&mut State, Option<usize>)>;
582type OnIndicesChanged<State> = Rc<dyn Fn(&mut State, Vec<usize>)>;
584type OnOverflow<State> = Rc<dyn Fn(&mut State, usize)>;
586
587pub struct ButtonGroupView<State: 'static> {
589 actions: Vec<ButtonGroupAction<State>>,
590 group_type: ButtonGroupType,
591 density: ButtonGroupDensity,
592 direction: ButtonGroupDirection,
593 spacing: Option<f64>,
594 variant: ButtonVariant,
595 size: ToggleButtonSize,
596 haptic: HapticSignal,
597 selected: Option<usize>,
598 selected_indices: Option<Vec<usize>>,
599 on_select: Option<OnSelect<State>>,
600 on_index_changed: Option<OnIndexChanged<State>>,
601 on_indices_changed: Option<OnIndicesChanged<State>>,
602 neighbor_squish: bool,
603 expanded_ratio: f64,
604 overflow: ButtonGroupOverflow,
605 overflow_icon: Option<IconFactory<State>>,
606 on_overflow: Option<OnOverflow<State>>,
607 overflow_anchor: Option<OverlayAnchor>,
608 semantic_label: Option<String>,
609}
610
611pub fn button_group_actions<State: 'static>(
621 actions: impl IntoIterator<Item = ButtonGroupAction<State>>,
622) -> ButtonGroupView<State> {
623 ButtonGroupView {
624 actions: actions.into_iter().collect(),
625 group_type: ButtonGroupType::default(),
626 density: ButtonGroupDensity::default(),
627 direction: ButtonGroupDirection::default(),
628 spacing: None,
629 variant: ButtonVariant::default(),
630 size: ToggleButtonSize::default(),
631 haptic: HapticSignal::None,
632 selected: None,
633 selected_indices: None,
634 on_select: None,
635 on_index_changed: None,
636 on_indices_changed: None,
637 neighbor_squish: true,
638 expanded_ratio: DEFAULT_EXPANDED_RATIO,
639 overflow: ButtonGroupOverflow::default(),
640 overflow_icon: None,
641 on_overflow: None,
642 overflow_anchor: None,
643 semantic_label: None,
644 }
645}
646
647pub fn button_group<State: 'static, F: Fn(&mut State, usize) + 'static>(
661 labels: impl IntoIterator<Item = impl Into<String>>,
662 selected: usize,
663 on_select: F,
664) -> ButtonGroupView<State> {
665 let actions = labels
666 .into_iter()
667 .map(|label| button_group_action::<State>(label));
668 button_group_actions(actions)
669 .group_type(ButtonGroupType::Connected)
670 .selected_index(Some(selected))
671 .on_select(on_select)
672}
673
674#[allow(non_snake_case)]
677pub fn ButtonGroup<State: 'static, F: Fn(&mut State, usize) + 'static>(
678 labels: impl IntoIterator<Item = impl Into<String>>,
679 selected: usize,
680 on_select: F,
681) -> ButtonGroupView<State> {
682 button_group(labels, selected, on_select)
683}
684
685impl<State: 'static> ButtonGroupView<State> {
686 pub fn group_type(mut self, group_type: ButtonGroupType) -> Self {
688 self.group_type = group_type;
689 self
690 }
691
692 pub fn density(mut self, density: ButtonGroupDensity) -> Self {
694 self.density = density;
695 self
696 }
697
698 pub fn direction(mut self, direction: ButtonGroupDirection) -> Self {
700 self.direction = direction;
701 self
702 }
703
704 pub fn spacing(mut self, spacing: f64) -> Self {
706 self.spacing = Some(spacing);
707 self
708 }
709
710 pub fn variant(mut self, variant: ButtonVariant) -> Self {
713 self.variant = variant;
714 self
715 }
716
717 pub fn size(mut self, size: ToggleButtonSize) -> Self {
719 self.size = size;
720 self
721 }
722
723 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
725 self.haptic = haptic;
726 self
727 }
728
729 pub fn selected_index(mut self, selected: Option<usize>) -> Self {
731 self.selected = selected;
732 self
733 }
734
735 pub fn selected_indices(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
738 let mut indices: Vec<usize> = indices.into_iter().collect();
739 indices.sort_unstable();
740 indices.dedup();
741 self.selected_indices = Some(indices);
742 self
743 }
744
745 pub fn on_select<F: Fn(&mut State, usize) + 'static>(mut self, on_select: F) -> Self {
748 self.on_select = Some(Rc::new(on_select));
749 self
750 }
751
752 pub fn on_selected_index_changed<F: Fn(&mut State, Option<usize>) + 'static>(
755 mut self,
756 on_change: F,
757 ) -> Self {
758 self.on_index_changed = Some(Rc::new(on_change));
759 self
760 }
761
762 pub fn on_selected_indices_changed<F: Fn(&mut State, Vec<usize>) + 'static>(
765 mut self,
766 on_change: F,
767 ) -> Self {
768 self.on_indices_changed = Some(Rc::new(on_change));
769 self
770 }
771
772 pub fn neighbor_squish(mut self, squish: bool) -> Self {
775 self.neighbor_squish = squish;
776 self
777 }
778
779 pub fn expanded_ratio(mut self, ratio: f64) -> Self {
782 self.expanded_ratio = ratio;
783 self
784 }
785
786 pub fn overflow(mut self, overflow: ButtonGroupOverflow) -> Self {
789 self.overflow = overflow;
790 self
791 }
792
793 pub fn overflow_icon<F: Fn() -> AnyView<State> + 'static>(mut self, icon: F) -> Self {
796 self.overflow_icon = Some(Rc::new(icon));
797 self
798 }
799
800 pub fn on_overflow<F: Fn(&mut State, usize) + 'static>(mut self, on_overflow: F) -> Self {
806 self.on_overflow = Some(Rc::new(on_overflow));
807 self
808 }
809
810 pub fn overflow_anchor(mut self, anchor: &OverlayAnchor) -> Self {
817 self.overflow_anchor = Some(anchor.clone());
818 self
819 }
820
821 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
824 self.semantic_label = Some(label.into());
825 self
826 }
827
828 fn is_selected(&self, index: usize) -> bool {
832 if let Some(set) = &self.selected_indices {
833 return set.contains(&index);
834 }
835 if self.on_index_changed.is_some() || self.selected.is_some() {
836 return self.selected == Some(index);
837 }
838 self.actions[index].checked.unwrap_or(false)
839 }
840
841 fn member_view(&self, index: usize) -> AnyView<State> {
844 let action = &self.actions[index];
845 let count = self.actions.len();
846 let connected = self.group_type == ButtonGroupType::Connected;
847
848 let on_indices = self.on_indices_changed.clone();
849 let on_index = self.on_index_changed.clone();
850 let on_select = self.on_select.clone();
851 let current = self.selected_indices.clone().unwrap_or_default();
852
853 let mut member = crate::toggle_button::toggle_button::<State, _>(
854 self.is_selected(index),
855 move |state: &mut State, checked: bool| {
856 if let Some(callback) = &on_indices {
857 let mut next = current.clone();
858 if checked {
859 if !next.contains(&index) {
860 next.push(index);
861 next.sort_unstable();
862 }
863 } else {
864 next.retain(|i| *i != index);
865 }
866 callback(state, next);
867 } else if let Some(callback) = &on_index {
868 callback(state, checked.then_some(index));
869 } else if let Some(callback) = &on_select {
870 callback(state, index);
871 }
872 },
873 )
874 .variant(self.variant)
875 .size(self.size)
876 .enabled(action.enabled)
877 .haptic(self.haptic)
878 .group_connected(connected)
879 .first_in_group(index == 0)
880 .last_in_group(index + 1 == count);
885
886 if let Some(icon) = &action.icon {
887 member = member.icon(icon());
888 }
889 if let Some(icon) = &action.checked_icon {
890 member = member.checked_icon(icon());
891 }
892 if let Some(label) = &action.label {
893 member = member.label(label.clone());
894 }
895 if let Some(label) = &action.checked_label {
896 member = member.checked_label(label.clone());
897 }
898 frust::authoring::any::<State, _>(member)
899 }
900
901 fn trigger_view(&self) -> AnyView<State> {
911 let icon = match &self.overflow_icon {
912 Some(factory) => factory(),
913 None => frust::authoring::any::<State, _>(frust::icon(crate::icons::MORE_HORIZ)),
914 };
915 let trigger =
916 crate::toggle_button::toggle_button::<State, _>(false, |_state: &mut State, _v| {})
917 .variant(self.variant)
918 .size(self.size)
919 .haptic(self.haptic)
920 .group_connected(self.group_type == ButtonGroupType::Connected)
921 .first_in_group(false)
922 .last_in_group(true)
923 .icon(icon);
924 match (&self.overflow_anchor, self.overflow) {
925 (Some(anchor), ButtonGroupOverflow::Popup) => {
926 frust::authoring::any::<State, _>(overlay_anchor(anchor, trigger))
927 }
928 _ => frust::authoring::any::<State, _>(trigger),
929 }
930 }
931
932 fn member_views(&self) -> Vec<AnyView<State>> {
934 (0..self.actions.len())
935 .map(|i| self.member_view(i))
936 .collect()
937 }
938
939 fn maybe_trigger_view(&self) -> Option<AnyView<State>> {
941 matches!(
942 self.overflow,
943 ButtonGroupOverflow::BottomSheet | ButtonGroupOverflow::Popup
944 )
945 .then(|| self.trigger_view())
946 }
947
948 pub fn overflow_menu(&self, first_hidden: usize, open: bool) -> AnyView<State> {
981 let anchor = self.overflow_anchor.clone().unwrap_or_default();
982 let start = first_hidden.min(self.actions.len());
983 let nodes: Vec<MenuNode> = self.actions[start..]
984 .iter()
985 .enumerate()
986 .map(|(offset, action)| {
987 menu_toggleable(action.accessible_label(), self.is_selected(start + offset))
988 .enabled(action.enabled)
989 .into()
990 })
991 .collect();
992
993 let on_indices = self.on_indices_changed.clone();
994 let on_index = self.on_index_changed.clone();
995 let on_select = self.on_select.clone();
996 let current = self.selected_indices.clone().unwrap_or_default();
997
998 frust::authoring::any::<State, _>(
999 menu(nodes, move |state: &mut State, selection: MenuSelection| {
1000 let index = start + selection.index;
1001 let checked = selection.checked().unwrap_or(true);
1002 if let Some(callback) = &on_indices {
1003 let mut next = current.clone();
1004 if checked {
1005 if !next.contains(&index) {
1006 next.push(index);
1007 next.sort_unstable();
1008 }
1009 } else {
1010 next.retain(|i| *i != index);
1011 }
1012 callback(state, next);
1013 } else if let Some(callback) = &on_index {
1014 callback(state, checked.then_some(index));
1015 } else if let Some(callback) = &on_select {
1016 callback(state, index);
1017 }
1018 })
1019 .anchor(&anchor)
1020 .open(open),
1021 )
1022 }
1023}
1024
1025#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1029enum Target {
1030 Member(usize),
1032 Trigger,
1034}
1035
1036pub struct ButtonGroupWidget {
1038 members: Vec<ChildPod>,
1040 trigger: Option<ChildPod>,
1043 labels: Vec<String>,
1045 widths: Vec<Option<f64>>,
1047 group_type: ButtonGroupType,
1048 density: ButtonGroupDensity,
1049 direction: ButtonGroupDirection,
1050 spacing: Option<f64>,
1051 neighbor_squish: bool,
1052 expanded_ratio: f64,
1053 overflow: ButtonGroupOverflow,
1054 semantic_label: Option<String>,
1055 on_overflow: Option<ErasedArgCallback<usize>>,
1056
1057 visible_count: usize,
1061 trigger_visible: bool,
1063 member_bases: Vec<Point>,
1065 trigger_base: Point,
1067 content_main: f64,
1069 viewport_main: f64,
1071
1072 scroll_offset: f64,
1075 active: Option<Target>,
1077 panning: bool,
1079 down_pos: Point,
1081 last_drag: Point,
1083 pressed_index: Option<usize>,
1086 squish: AnimationController,
1088 laid_out_squish: SquishState,
1092}
1093
1094type SquishState = (f64, Option<usize>);
1098
1099impl ButtonGroupWidget {
1100 pub fn supports_squish(&self) -> bool {
1104 self.direction == ButtonGroupDirection::Horizontal
1105 && self.group_type != ButtonGroupType::Connected
1106 && self.neighbor_squish
1107 }
1108
1109 fn squish_state(&self) -> SquishState {
1113 let anim = if self.supports_squish() {
1114 self.squish.value_clamped()
1115 } else {
1116 0.0
1117 };
1118 (anim, self.pressed_index)
1119 }
1120
1121 fn max_scroll(&self) -> f64 {
1123 (self.content_main - self.viewport_main).max(0.0)
1124 }
1125
1126 fn can_scroll(&self) -> bool {
1128 self.overflow == ButtonGroupOverflow::Scroll && self.max_scroll() > FIT_EPSILON
1129 }
1130
1131 fn main_of(&self, p: Point) -> f64 {
1133 match self.direction {
1134 ButtonGroupDirection::Horizontal => p.x,
1135 ButtonGroupDirection::Vertical => p.y,
1136 }
1137 }
1138
1139 fn pod_mut(&mut self, target: Target) -> Option<&mut ChildPod> {
1141 match target {
1142 Target::Member(i) => self.members.get_mut(i),
1143 Target::Trigger => self.trigger.as_mut(),
1144 }
1145 }
1146
1147 fn hit(&self, pos: Point) -> Option<Target> {
1150 if self.trigger_visible
1151 && let Some(trigger) = &self.trigger
1152 && trigger.contains(pos)
1153 {
1154 return Some(Target::Trigger);
1155 }
1156 let visible = self.visible_count.min(self.members.len());
1157 self.members[..visible]
1158 .iter()
1159 .position(|pod| pod.contains(pos))
1160 .map(Target::Member)
1161 }
1162
1163 fn press_in(&mut self) {
1165 self.squish.fling(FLING_VELOCITY, SQUISH_SPRING);
1166 }
1167
1168 fn press_out(&mut self) {
1170 self.squish.fling(-FLING_VELOCITY, SQUISH_SPRING);
1171 }
1172
1173 fn sync_origins(&mut self) {
1176 let horizontal = self.direction == ButtonGroupDirection::Horizontal;
1177 let offset = self.scroll_offset;
1178 let visible = self.visible_count.min(self.members.len());
1179 for (pod, base) in self.members[..visible].iter_mut().zip(&self.member_bases) {
1180 pod.set_origin(shift_main(*base, -offset, horizontal));
1181 }
1182 if self.trigger_visible
1183 && let Some(trigger) = &mut self.trigger
1184 {
1185 trigger.set_origin(shift_main(self.trigger_base, -offset, horizontal));
1186 }
1187 }
1188
1189 fn pan_by(&mut self, delta: f64) -> bool {
1192 let max = self.max_scroll();
1193 let next = (self.scroll_offset - delta).clamp(0.0, max);
1194 if (next - self.scroll_offset).abs() < f64::EPSILON {
1195 return false;
1196 }
1197 self.scroll_offset = next;
1198 self.sync_origins();
1199 true
1200 }
1201
1202 fn send_cancel(&mut self, ctx: &mut EventCtx, target: Target, pos: Point) {
1205 let cancel = InputEvent::Pointer(PointerEvent {
1206 phase: PointerPhase::Cancel,
1207 position: pos,
1208 button: PointerButton::Primary,
1209 });
1210 if let Some(pod) = self.pod_mut(target) {
1211 pod.event_child(ctx, &cancel);
1212 pod.set_active(false);
1213 }
1214 }
1215
1216 fn broadcast(&mut self, ctx: &mut EventCtx, event: &InputEvent) {
1219 for pod in self.members.iter_mut() {
1220 pod.event_child(ctx, event);
1221 }
1222 if let Some(trigger) = &mut self.trigger {
1223 trigger.event_child(ctx, event);
1224 }
1225 }
1226
1227 fn route_focused(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1230 if let Some(pod) = self.members.iter_mut().find(|pod| pod.is_focused()) {
1231 return pod.event_child(ctx, event);
1232 }
1233 if let Some(trigger) = self.trigger.as_mut().filter(|pod| pod.is_focused()) {
1234 return trigger.event_child(ctx, event);
1235 }
1236 EventResult::Ignored
1237 }
1238
1239 fn apply_config<State: 'static>(&mut self, view: &ButtonGroupView<State>) -> ChangeFlags {
1242 let mut flags = ChangeFlags::NONE;
1243 if self.group_type != view.group_type
1244 || self.density != view.density
1245 || self.direction != view.direction
1246 || self.spacing != view.spacing
1247 || self.overflow != view.overflow
1248 || self.neighbor_squish != view.neighbor_squish
1249 || self.expanded_ratio != view.expanded_ratio
1250 {
1251 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1252 }
1253 self.group_type = view.group_type;
1254 self.density = view.density;
1255 self.direction = view.direction;
1256 self.spacing = view.spacing;
1257 self.overflow = view.overflow;
1258 self.neighbor_squish = view.neighbor_squish;
1259 self.expanded_ratio = view.expanded_ratio;
1260 self.semantic_label = view.semantic_label.clone();
1261 self.labels = view
1262 .actions
1263 .iter()
1264 .map(ButtonGroupAction::accessible_label)
1265 .collect();
1266 self.widths = view.actions.iter().map(|a| a.width).collect();
1267 self.on_overflow = view
1268 .on_overflow
1269 .as_ref()
1270 .map(frust::authoring::erase_callback_arg);
1271 flags
1272 }
1273}
1274
1275fn shift_main(base: Point, delta: f64, horizontal: bool) -> Point {
1277 if horizontal {
1278 Point::new(base.x + delta, base.y)
1279 } else {
1280 Point::new(base.x, base.y + delta)
1281 }
1282}
1283
1284impl<State: 'static> View<State> for ButtonGroupView<State> {
1285 type Element = ButtonGroupWidget;
1286
1287 fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonGroupWidget {
1288 let members: Vec<ChildPod> = self
1289 .member_views()
1290 .iter()
1291 .map(|view| frust::authoring::build_child(view, ctx))
1292 .collect();
1293 let trigger = self
1294 .maybe_trigger_view()
1295 .map(|view| frust::authoring::build_child(&view, ctx));
1296 let mut widget = ButtonGroupWidget {
1297 visible_count: members.len(),
1298 members,
1299 trigger,
1300 labels: Vec::new(),
1301 widths: Vec::new(),
1302 group_type: self.group_type,
1303 density: self.density,
1304 direction: self.direction,
1305 spacing: self.spacing,
1306 neighbor_squish: self.neighbor_squish,
1307 expanded_ratio: self.expanded_ratio,
1308 overflow: self.overflow,
1309 semantic_label: None,
1310 on_overflow: None,
1311 trigger_visible: false,
1312 member_bases: Vec::new(),
1313 trigger_base: Point::ZERO,
1314 content_main: 0.0,
1315 viewport_main: 0.0,
1316 scroll_offset: 0.0,
1317 active: None,
1318 panning: false,
1319 down_pos: Point::ZERO,
1320 last_drag: Point::ZERO,
1321 pressed_index: None,
1322 squish: AnimationController::new(SQUISH_ANIM_PERIOD),
1323 laid_out_squish: (0.0, None),
1324 };
1325 widget.apply_config(self);
1326 widget
1327 }
1328
1329 fn rebuild(
1330 &self,
1331 prev: &Self,
1332 element: &mut ButtonGroupWidget,
1333 ctx: &mut BuildCtx<'_>,
1334 ) -> ChangeFlags {
1335 let prev_views = prev.member_views();
1339 let next_views = self.member_views();
1340 let mut flags = frust::authoring::rebuild_children(
1341 &prev_views,
1342 &next_views,
1343 &mut element.members,
1344 ctx,
1345 |view| view,
1346 |_| None,
1347 );
1348
1349 match (prev.maybe_trigger_view(), self.maybe_trigger_view()) {
1350 (Some(prev_view), Some(next_view)) => {
1351 if let Some(pod) = element.trigger.as_mut() {
1352 flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
1353 }
1354 }
1355 (Some(prev_view), None) => {
1356 if let Some(pod) = element.trigger.as_mut() {
1357 frust::authoring::teardown_child(&prev_view, pod, ctx);
1358 }
1359 element.trigger = None;
1360 element.trigger_visible = false;
1361 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1362 }
1363 (None, Some(next_view)) => {
1364 element.trigger = Some(frust::authoring::build_child(&next_view, ctx));
1365 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1366 }
1367 (None, None) => {}
1368 }
1369
1370 if prev.actions.len() != self.actions.len() {
1371 element.active = None;
1373 element.panning = false;
1374 element.pressed_index = None;
1375 element.scroll_offset = 0.0;
1376 element.visible_count = element.members.len();
1377 }
1378 flags |= element.apply_config(self);
1379 flags
1380 }
1381
1382 fn teardown(&self, element: &mut ButtonGroupWidget, ctx: &mut BuildCtx<'_>) {
1383 for (view, pod) in self.member_views().iter().zip(element.members.iter_mut()) {
1384 frust::authoring::teardown_child(view, pod, ctx);
1385 }
1386 if let (Some(view), Some(pod)) = (self.maybe_trigger_view(), element.trigger.as_mut()) {
1387 frust::authoring::teardown_child(&view, pod, ctx);
1388 }
1389 }
1390}
1391
1392impl Widget for ButtonGroupWidget {
1393 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1394 let horizontal = self.direction == ButtonGroupDirection::Horizontal;
1395 let max = bc.max();
1396 let (max_main, max_cross) = if horizontal {
1397 (max.width, max.height)
1398 } else {
1399 (max.height, max.width)
1400 };
1401 self.member_bases.clear();
1402 if self.members.is_empty() {
1403 self.visible_count = 0;
1404 self.trigger_visible = false;
1405 self.content_main = 0.0;
1406 self.viewport_main = 0.0;
1407 return bc.constrain(Size::ZERO);
1408 }
1409
1410 let spacing = resolve_spacing(self.spacing, self.group_type, self.density);
1411
1412 let natural_bc = if horizontal {
1415 BoxConstraints::loose(Size::new(f64::INFINITY, max_cross))
1416 } else {
1417 BoxConstraints::loose(Size::new(max_cross, f64::INFINITY))
1418 };
1419 let mut natural = Vec::with_capacity(self.members.len());
1420 for (i, pod) in self.members.iter_mut().enumerate() {
1421 let size = pod.layout_child(ctx, &natural_bc);
1422 let measured = if horizontal { size.width } else { size.height };
1423 natural.push(self.widths.get(i).copied().flatten().unwrap_or(measured));
1424 }
1425 let trigger_natural = self.trigger.as_mut().map(|pod| {
1426 let size = pod.layout_child(ctx, &natural_bc);
1427 if horizontal { size.width } else { size.height }
1428 });
1429
1430 let (visible_count, trigger_visible) = match (self.overflow, trigger_natural) {
1432 (ButtonGroupOverflow::BottomSheet | ButtonGroupOverflow::Popup, Some(trigger))
1433 if max_main.is_finite() =>
1434 {
1435 let fit = visible_count_for_overflow(max_main, &natural, trigger, spacing);
1436 if fit >= natural.len() {
1437 (natural.len(), false)
1438 } else {
1439 (fit, true)
1440 }
1441 }
1442 _ => (natural.len(), false),
1443 };
1444 self.visible_count = visible_count;
1445 self.trigger_visible = trigger_visible;
1446
1447 let state = self.squish_state();
1451 self.laid_out_squish = state;
1452 let (anim, pressed) = state;
1453 let pressed = pressed.filter(|i| *i < visible_count);
1454 let mut sizes = squish_sizes(
1455 &natural[..visible_count],
1456 pressed,
1457 self.expanded_ratio,
1458 anim,
1459 );
1460 if let Some(trigger) = trigger_natural.filter(|_| trigger_visible) {
1461 sizes.push(trigger);
1462 }
1463
1464 let mut cross = 0.0_f64;
1466 let mut child_sizes = Vec::with_capacity(sizes.len());
1467 {
1468 let pods = self.members[..visible_count]
1469 .iter_mut()
1470 .chain(self.trigger.iter_mut().filter(|_| trigger_visible));
1471 for (pod, main) in pods.zip(sizes.iter()) {
1472 let child_bc = if horizontal {
1473 BoxConstraints::new(Size::new(*main, 0.0), Size::new(*main, max_cross))
1474 } else {
1475 BoxConstraints::new(Size::new(0.0, *main), Size::new(max_cross, *main))
1476 };
1477 let size = pod.layout_child(ctx, &child_bc);
1478 cross = cross.max(if horizontal { size.height } else { size.width });
1479 child_sizes.push(size);
1480 }
1481 }
1482
1483 let laid_out = sizes.len();
1484 let total_main: f64 = sizes.iter().sum::<f64>()
1485 + if laid_out > 0 {
1486 spacing * (laid_out - 1) as f64
1487 } else {
1488 0.0
1489 };
1490
1491 let mut main_offset = 0.0_f64;
1492 let mut bases = Vec::with_capacity(laid_out);
1493 for (main, size) in sizes.iter().zip(child_sizes.iter()) {
1494 let child_cross = if horizontal { size.height } else { size.width };
1495 let cross_offset = ((cross - child_cross) / 2.0).max(0.0);
1496 bases.push(if horizontal {
1497 Point::new(main_offset, cross_offset)
1498 } else {
1499 Point::new(cross_offset, main_offset)
1500 });
1501 main_offset += main + spacing;
1502 }
1503 self.trigger_base = if trigger_visible {
1504 bases.pop().unwrap_or(Point::ZERO)
1505 } else {
1506 Point::ZERO
1507 };
1508 self.member_bases = bases;
1509
1510 let size = bc.constrain(if horizontal {
1511 Size::new(total_main, cross)
1512 } else {
1513 Size::new(cross, total_main)
1514 });
1515 self.content_main = total_main;
1516 self.viewport_main = if horizontal { size.width } else { size.height };
1517 self.scroll_offset = self.scroll_offset.clamp(0.0, self.max_scroll());
1518 self.sync_origins();
1519 size
1520 }
1521
1522 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1523 let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
1524
1525 if reduce_motion {
1526 if self.squish.is_animating() {
1529 self.squish.stop();
1530 }
1531 self.pressed_index = None;
1532 } else {
1533 if self.squish.advance(ctx.frame_time()) {
1537 ctx.request_layout();
1538 } else if self.active.is_none()
1539 && self.squish.value().abs() < SQUISH_REST_EPSILON
1540 && self.pressed_index.is_some()
1541 {
1542 self.pressed_index = None;
1543 }
1544 }
1545 if self.squish_state() != self.laid_out_squish {
1555 ctx.request_layout();
1556 }
1557
1558 let clipped =
1559 self.overflow == ButtonGroupOverflow::Scroll && self.max_scroll() > FIT_EPSILON;
1560 if clipped {
1561 scene.push_clip(ctx.origin(), ctx.size());
1562 }
1563 let visible = self.visible_count.min(self.members.len());
1564 for pod in self.members[..visible].iter_mut() {
1565 pod.paint_child(ctx, scene);
1566 }
1567 if self.trigger_visible
1568 && let Some(trigger) = &mut self.trigger
1569 {
1570 trigger.paint_child(ctx, scene);
1571 }
1572 if clipped {
1573 scene.pop_clip();
1574 }
1575 }
1576
1577 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1578 if event.is_broadcast() {
1579 self.broadcast(ctx, event);
1580 return EventResult::Ignored;
1581 }
1582 if event.is_focus_routed() {
1583 return self.route_focused(ctx, event);
1584 }
1585 match event {
1586 InputEvent::Scroll { delta, .. } => {
1587 if !self.can_scroll() {
1588 return EventResult::Ignored;
1589 }
1590 let (dx, dy) = match delta {
1591 ScrollDelta::Lines(x, y) => (x * WHEEL_LINE_PX, y * WHEEL_LINE_PX),
1592 ScrollDelta::Pixels(x, y) => (*x, *y),
1593 };
1594 let main = match self.direction {
1598 ButtonGroupDirection::Horizontal if dx != 0.0 => dx,
1599 ButtonGroupDirection::Horizontal => dy,
1600 ButtonGroupDirection::Vertical => dy,
1601 };
1602 if self.pan_by(-main) {
1603 ctx.request_redraw();
1604 }
1605 EventResult::Handled
1606 }
1607 InputEvent::Pointer(p) => match p.phase {
1608 PointerPhase::Down => {
1609 let Some(target) = self.hit(p.position) else {
1610 return EventResult::Ignored;
1611 };
1612 if !presses(p) {
1613 return self
1616 .pod_mut(target)
1617 .map(|pod| pod.event_child(ctx, event))
1618 .unwrap_or(EventResult::Ignored);
1619 }
1620 self.active = Some(target);
1621 self.panning = false;
1622 self.down_pos = p.position;
1623 self.last_drag = p.position;
1624 if let Target::Member(i) = target {
1625 self.pressed_index = Some(i);
1626 self.press_in();
1627 }
1628 ctx.capture_pointer();
1629 if let Some(pod) = self.pod_mut(target) {
1630 pod.event_child(ctx, event);
1631 }
1632 ctx.request_redraw();
1633 EventResult::Handled
1634 }
1635 PointerPhase::Move => {
1636 let Some(target) = self.active else {
1637 return match self.hit(p.position) {
1640 Some(hit) => self
1641 .pod_mut(hit)
1642 .map(|pod| pod.event_child(ctx, event))
1643 .unwrap_or(EventResult::Ignored),
1644 None => EventResult::Ignored,
1645 };
1646 };
1647 if self.panning {
1648 let delta = self.main_of(p.position) - self.main_of(self.last_drag);
1649 self.last_drag = p.position;
1650 if self.pan_by(delta) {
1651 ctx.request_redraw();
1652 }
1653 return EventResult::Handled;
1654 }
1655 let travelled = self.main_of(p.position) - self.main_of(self.down_pos);
1656 if self.can_scroll() && travelled.abs() > TOUCH_SLOP {
1657 self.panning = true;
1660 self.last_drag = p.position;
1661 self.send_cancel(ctx, target, p.position);
1662 self.press_out();
1663 ctx.request_redraw();
1664 return EventResult::Handled;
1665 }
1666 if let Some(pod) = self.pod_mut(target) {
1667 pod.event_child(ctx, event);
1668 }
1669 EventResult::Handled
1670 }
1671 PointerPhase::Up => {
1672 let Some(target) = self.active.take() else {
1673 return EventResult::Ignored;
1674 };
1675 let panned = self.panning;
1676 self.panning = false;
1677 if !panned {
1678 if let Some(pod) = self.pod_mut(target) {
1679 pod.event_child(ctx, event);
1680 }
1681 if target == Target::Trigger
1684 && self
1685 .trigger
1686 .as_ref()
1687 .is_some_and(|pod| pod.contains(p.position))
1688 {
1689 let first_hidden = self.visible_count;
1690 if let Some(callback) = self.on_overflow.as_mut() {
1691 callback(ctx, first_hidden);
1692 }
1693 }
1694 }
1695 if let Some(pod) = self.pod_mut(target) {
1696 pod.set_active(false);
1697 }
1698 self.press_out();
1699 ctx.request_redraw();
1700 EventResult::Handled
1701 }
1702 PointerPhase::Cancel => {
1703 let Some(target) = self.active.take() else {
1704 return EventResult::Ignored;
1705 };
1706 if !self.panning
1707 && let Some(pod) = self.pod_mut(target)
1708 {
1709 pod.event_child(ctx, event);
1710 }
1711 if let Some(pod) = self.pod_mut(target) {
1712 pod.set_active(false);
1713 }
1714 self.panning = false;
1715 self.press_out();
1716 ctx.request_redraw();
1717 EventResult::Handled
1718 }
1719 },
1720 _ => EventResult::Ignored,
1721 }
1722 }
1723
1724 fn semantics(&self, ctx: &mut SemanticsCtx) {
1725 let label = self.semantic_label.clone();
1726 ctx.push_container(
1727 Role::Group,
1728 |node| {
1729 if let Some(label) = &label {
1730 node.set_label(label.as_str());
1731 }
1732 },
1733 |ctx| {
1734 let visible = self.visible_count.min(self.members.len());
1735 for pod in &self.members[..visible] {
1736 pod.semantics_child(ctx);
1737 }
1738 if self.trigger_visible
1739 && let Some(trigger) = &self.trigger
1740 {
1741 trigger.semantics_child(ctx);
1742 }
1743 },
1744 );
1745 }
1746
1747 frust::authoring::visit_children!(members, trigger);
1748}
1749
1750#[cfg(test)]
1751mod tests {
1752 use super::*;
1753 use frust::FrameTime;
1754 use frust::authoring::text::TextContext;
1755 use frust_widgets::test_support::leaf_any;
1756 use kurbo::Rect;
1757 use std::any::Any;
1758 use std::cell::RefCell;
1759
1760 const ICON_MEMBER_W: f64 = 36.0;
1767 const MEMBER_H: f64 = 40.0;
1768
1769 fn bare_icon_actions(count: usize) -> Vec<ButtonGroupAction<()>> {
1772 (0..count)
1773 .map(|_| button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)))
1774 .collect()
1775 }
1776
1777 fn build<S: 'static>(view: &ButtonGroupView<S>) -> ButtonGroupWidget {
1778 let mut counter = 0u64;
1779 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1780 }
1781
1782 fn layout(widget: &mut ButtonGroupWidget, max: Size) -> Size {
1783 let mut tcx = TextContext::new();
1784 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1785 widget.layout(&mut lctx, &BoxConstraints::loose(max))
1786 }
1787
1788 fn layout_themed(widget: &mut ButtonGroupWidget, max: Size, theme: &Theme) -> Size {
1789 let mut tcx = TextContext::new();
1790 let mut lctx =
1791 LayoutCtx::with_text_context(&mut tcx as &mut dyn Any).with_theme(theme as &dyn Any);
1792 widget.layout(&mut lctx, &BoxConstraints::loose(max))
1793 }
1794
1795 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1796 InputEvent::Pointer(PointerEvent {
1797 phase,
1798 position: Point::new(x, y),
1799 button: PointerButton::Primary,
1800 })
1801 }
1802
1803 fn dispatch<S: 'static>(
1804 widget: &mut ButtonGroupWidget,
1805 state: &mut S,
1806 size: Size,
1807 event: &InputEvent,
1808 ) {
1809 let state_any: &mut dyn Any = state;
1810 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1811 widget.event(&mut ctx, event);
1812 }
1813
1814 fn advance_squish(widget: &mut ButtonGroupWidget, secs: f64) {
1816 widget.squish.advance(FrameTime::ZERO);
1817 widget
1818 .squish
1819 .advance(FrameTime::from_nanos((secs * 1e9) as u64));
1820 }
1821
1822 #[derive(Default)]
1823 struct NullScene;
1824
1825 impl PaintScene for NullScene {
1826 fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
1827 fn draw_text(&mut self, _o: Point, _t: &str) {}
1828 }
1829
1830 fn paint(widget: &mut ButtonGroupWidget, size: Size, theme: Option<&Theme>) {
1831 let mut scene = NullScene;
1832 let mut ctx = PaintCtx::new(Point::ZERO, size);
1833 if let Some(theme) = theme {
1834 ctx = ctx.with_theme(theme as &dyn Any);
1835 }
1836 widget.paint(&mut ctx, &mut scene);
1837 }
1838
1839 #[test]
1842 fn single_member_group_is_all_outer_corners() {
1843 let r = member_radii(0, 1, OUTER_RADIUS, INNER_RADIUS);
1844 assert_eq!(r.top_left, 16.0);
1845 assert_eq!(r.top_right, 16.0);
1846 assert_eq!(r.bottom_right, 16.0);
1847 assert_eq!(r.bottom_left, 16.0);
1848 }
1849
1850 #[test]
1851 fn end_members_get_outer_on_their_outer_side_only() {
1852 let first = member_radii(0, 3, 16.0, 4.0);
1853 assert_eq!((first.top_left, first.bottom_left), (16.0, 16.0));
1854 assert_eq!((first.top_right, first.bottom_right), (4.0, 4.0));
1855 let last = member_radii(2, 3, 16.0, 4.0);
1856 assert_eq!((last.top_left, last.bottom_left), (4.0, 4.0));
1857 assert_eq!((last.top_right, last.bottom_right), (16.0, 16.0));
1858 }
1859
1860 #[test]
1861 fn middle_members_are_all_inner_corners() {
1862 let mid = member_radii(1, 3, 16.0, 4.0);
1863 assert_eq!(mid.top_left, 4.0);
1864 assert_eq!(mid.top_right, 4.0);
1865 assert_eq!(mid.bottom_right, 4.0);
1866 assert_eq!(mid.bottom_left, 4.0);
1867 }
1868
1869 #[test]
1872 fn resolved_gap_pins_every_type_density_pair() {
1873 use ButtonGroupDensity::{Compact, Regular};
1874 use ButtonGroupType::{Connected, Standard};
1875 assert_eq!(resolve_spacing(None, Standard, Regular), 8.0);
1876 assert_eq!(resolve_spacing(None, Standard, Compact), 6.0);
1878 assert_eq!(resolve_spacing(None, Connected, Regular), 2.0);
1881 assert_eq!(resolve_spacing(None, Connected, Compact), 2.0);
1882 assert_eq!(resolve_spacing(Some(3.5), Connected, Compact), 3.5);
1884 }
1885
1886 #[test]
1889 fn squish_middle_splits_the_growth_between_both_neighbours() {
1890 let natural = [100.0, 200.0, 100.0];
1891 let sizes = squish_sizes(&natural, Some(1), 0.15, 1.0);
1892 assert_eq!(sizes, vec![85.0, 230.0, 85.0]);
1894 }
1895
1896 #[test]
1897 fn squish_at_the_first_index_moves_all_growth_to_its_only_neighbour() {
1898 let sizes = squish_sizes(&[100.0, 100.0, 100.0], Some(0), 0.15, 1.0);
1899 assert_eq!(sizes, vec![115.0, 85.0, 100.0]);
1900 }
1901
1902 #[test]
1903 fn squish_at_the_last_index_mirrors_the_first() {
1904 let sizes = squish_sizes(&[100.0, 100.0, 100.0], Some(2), 0.15, 1.0);
1905 assert_eq!(sizes, vec![100.0, 85.0, 115.0]);
1906 }
1907
1908 #[test]
1909 fn squish_preserves_the_total_main_extent() {
1910 for pressed in 0..3 {
1911 let natural = [80.0, 120.0, 60.0];
1912 let sizes = squish_sizes(&natural, Some(pressed), 0.15, 0.6);
1913 let before: f64 = natural.iter().sum();
1914 let after: f64 = sizes.iter().sum();
1915 assert!(
1916 (before - after).abs() < 1e-9,
1917 "pressing {pressed} changed the total: {before} -> {after}"
1918 );
1919 }
1920 }
1921
1922 #[test]
1923 fn squish_scales_with_the_spring_progress_and_the_ratio() {
1924 let half = squish_sizes(&[100.0, 100.0], Some(0), 0.15, 0.5);
1925 assert_eq!(half, vec![107.5, 92.5]);
1926 let wider = squish_sizes(&[100.0, 100.0], Some(0), 0.30, 1.0);
1927 assert_eq!(wider, vec![130.0, 70.0]);
1928 }
1929
1930 #[test]
1931 fn squish_is_inert_without_a_press_or_progress() {
1932 let natural = [100.0, 100.0];
1933 assert_eq!(squish_sizes(&natural, None, 0.15, 1.0), natural.to_vec());
1934 assert_eq!(squish_sizes(&natural, Some(0), 0.15, 0.0), natural.to_vec());
1935 assert_eq!(squish_sizes(&natural, Some(9), 0.15, 1.0), natural.to_vec());
1937 }
1938
1939 #[test]
1940 fn squish_of_a_one_member_group_grows_with_nothing_to_absorb_it() {
1941 assert_eq!(squish_sizes(&[100.0], Some(0), 0.15, 1.0), vec![115.0]);
1942 }
1943
1944 #[test]
1945 fn squish_shrink_is_floored_at_zero() {
1946 let sizes = squish_sizes(&[400.0, 10.0], Some(0), 0.15, 1.0);
1948 assert_eq!(sizes, vec![460.0, 0.0]);
1949 }
1950
1951 #[test]
1952 fn squish_applies_only_to_a_horizontal_unconnected_group() {
1953 let cases = [
1954 (
1955 ButtonGroupDirection::Horizontal,
1956 ButtonGroupType::Standard,
1957 true,
1958 true,
1959 ),
1960 (
1961 ButtonGroupDirection::Horizontal,
1962 ButtonGroupType::Connected,
1963 true,
1964 false,
1965 ),
1966 (
1967 ButtonGroupDirection::Vertical,
1968 ButtonGroupType::Standard,
1969 true,
1970 false,
1971 ),
1972 (
1973 ButtonGroupDirection::Horizontal,
1974 ButtonGroupType::Standard,
1975 false,
1976 false,
1977 ),
1978 ];
1979 for (direction, group_type, squish, expected) in cases {
1980 let view = button_group_actions(bare_icon_actions(3))
1981 .direction(direction)
1982 .group_type(group_type)
1983 .neighbor_squish(squish);
1984 let widget = build(&view);
1985 assert_eq!(
1986 widget.supports_squish(),
1987 expected,
1988 "{direction:?}/{group_type:?}/squish={squish}"
1989 );
1990 }
1991 }
1992
1993 #[test]
1996 fn visible_count_is_every_item_when_they_all_fit() {
1997 assert_eq!(visible_count_for_overflow(200.0, &[36.0; 3], 36.0, 8.0), 3);
1999 }
2000
2001 #[test]
2002 fn visible_count_reserves_the_trigger_and_its_separator() {
2003 assert_eq!(visible_count_for_overflow(100.0, &[36.0; 4], 36.0, 8.0), 1);
2006 }
2007
2008 #[test]
2009 fn visible_count_rounds_consumed_up_and_available_down() {
2010 assert_eq!(
2014 visible_count_for_overflow(99.9, &[36.2, 36.2, 36.2], 36.0, 8.0),
2015 1
2016 );
2017 }
2018
2019 #[test]
2020 fn visible_count_is_zero_when_even_one_item_cannot_fit() {
2021 assert_eq!(visible_count_for_overflow(40.0, &[36.0; 3], 36.0, 8.0), 0);
2022 }
2023
2024 #[test]
2027 fn build_creates_one_pod_per_action() {
2028 let view = button_group::<u32, _>(vec!["Day", "Week", "Month"], 0, |s: &mut u32, i| {
2029 *s = i as u32
2030 });
2031 let widget = build(&view);
2032 assert_eq!(widget.members.len(), 3);
2033 assert_eq!(widget.labels, vec!["Day", "Week", "Month"]);
2034 assert!(widget.trigger.is_none(), "no trigger without BottomSheet");
2035 }
2036
2037 #[test]
2038 fn the_legacy_constructor_stays_connected_while_the_action_one_is_standard() {
2039 let legacy = build(&button_group::<(), _>(vec!["A", "B"], 0, |_s, _i| {}));
2040 assert_eq!(legacy.group_type, ButtonGroupType::Connected);
2041 let ported = build(&button_group_actions(bare_icon_actions(2)));
2042 assert_eq!(ported.group_type, ButtonGroupType::Standard);
2043 assert_eq!(ported.overflow, ButtonGroupOverflow::Scroll);
2044 }
2045
2046 #[test]
2047 fn a_bottom_sheet_group_builds_a_trigger_pod() {
2048 let view =
2049 button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::BottomSheet);
2050 let widget = build(&view);
2051 assert!(widget.trigger.is_some());
2052 }
2053
2054 #[test]
2057 fn members_abut_with_the_resolved_gap() {
2058 let mut widget = build(&button_group_actions(bare_icon_actions(3)));
2059 let size = layout(&mut widget, Size::new(500.0, 200.0));
2060 assert_eq!(size, Size::new(3.0 * ICON_MEMBER_W + 16.0, MEMBER_H));
2062 assert_eq!(widget.member_bases[0].x, 0.0);
2063 assert_eq!(widget.member_bases[1].x, ICON_MEMBER_W + 8.0);
2064 assert_eq!(widget.member_bases[2].x, 2.0 * (ICON_MEMBER_W + 8.0));
2065 }
2066
2067 #[test]
2068 fn a_connected_group_uses_the_hairline_seam() {
2069 let mut widget = build(
2070 &button_group_actions(bare_icon_actions(3)).group_type(ButtonGroupType::Connected),
2071 );
2072 layout(&mut widget, Size::new(500.0, 200.0));
2073 assert_eq!(widget.member_bases[1].x, ICON_MEMBER_W + CONNECTED_GAP);
2074 }
2075
2076 #[test]
2077 fn a_vertical_group_stacks_along_the_cross_axis() {
2078 let mut widget = build(
2079 &button_group_actions(bare_icon_actions(2)).direction(ButtonGroupDirection::Vertical),
2080 );
2081 let size = layout(&mut widget, Size::new(200.0, 500.0));
2082 assert_eq!(size, Size::new(ICON_MEMBER_W, 2.0 * MEMBER_H + 8.0));
2083 assert_eq!(widget.member_bases[1].y, MEMBER_H + 8.0);
2084 }
2085
2086 #[test]
2087 fn a_pinned_action_width_overrides_the_natural_extent() {
2088 let actions = vec![
2089 button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).width(90.0),
2090 button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
2091 ];
2092 let mut widget = build(&button_group_actions(actions));
2093 let size = layout(&mut widget, Size::new(500.0, 200.0));
2094 assert_eq!(size.width, 90.0 + 8.0 + ICON_MEMBER_W);
2095 assert_eq!(widget.member_bases[1].x, 98.0);
2096 }
2097
2098 #[test]
2099 fn pressing_a_member_squishes_its_neighbour_in_layout() {
2100 let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2101 let unpressed = layout(&mut widget, Size::new(500.0, 200.0));
2102 let gap_start = widget.member_bases[1].x;
2103
2104 let mut state = ();
2105 dispatch(
2106 &mut widget,
2107 &mut state,
2108 unpressed,
2109 &ev(PointerPhase::Down, 4.0, 20.0),
2110 );
2111 advance_squish(&mut widget, 0.05);
2112 let pressed = layout(&mut widget, Size::new(500.0, 200.0));
2113
2114 assert!(
2115 widget.member_bases[1].x > gap_start,
2116 "member 0 grew, pushing member 1 along ({} -> {})",
2117 gap_start,
2118 widget.member_bases[1].x
2119 );
2120 assert!(
2121 (pressed.width - unpressed.width).abs() < 1e-6,
2122 "the squish is extent-preserving: {unpressed:?} -> {pressed:?}"
2123 );
2124 }
2125
2126 fn labelled_actions() -> Vec<ButtonGroupAction<()>> {
2131 vec![
2132 button_group_action("Every day"),
2133 button_group_action("Days per week"),
2134 button_group_action("Selected days"),
2135 ]
2136 }
2137
2138 #[derive(Default)]
2142 struct GlyphCounter {
2143 runs: Vec<usize>,
2144 }
2145
2146 impl PaintScene for GlyphCounter {
2147 fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
2148 fn draw_text(&mut self, _o: Point, _t: &str) {}
2149 fn draw_glyph_run(&mut self, run: frust::authoring::scene::GlyphRun) {
2150 self.runs.push(run.glyphs.len());
2151 }
2152 }
2153
2154 struct Frames {
2160 widget: ButtonGroupWidget,
2161 text: TextContext,
2162 theme: Theme,
2163 max: Size,
2164 size: Size,
2165 needs_layout: bool,
2166 }
2167
2168 impl Frames {
2169 fn new(view: &ButtonGroupView<()>, max: Size) -> Self {
2170 Frames {
2171 widget: build(view),
2172 text: TextContext::new(),
2173 theme: crate::baseline(),
2174 max,
2175 size: Size::ZERO,
2176 needs_layout: true,
2177 }
2178 }
2179
2180 fn run(&mut self, t_ms: u64) -> Vec<usize> {
2183 if self.needs_layout {
2184 let mut lctx = LayoutCtx::with_text_context(&mut self.text as &mut dyn Any)
2185 .with_theme(&self.theme as &dyn Any);
2186 self.size = self
2187 .widget
2188 .layout(&mut lctx, &BoxConstraints::loose(self.max));
2189 }
2190 let mut scene = GlyphCounter::default();
2191 let mut ctx = PaintCtx::for_test(
2192 Point::ZERO,
2193 self.size,
2194 FrameTime::from_nanos(t_ms * 1_000_000),
2195 )
2196 .with_theme(&self.theme as &dyn Any);
2197 self.widget.paint(&mut ctx, &mut scene);
2198 self.needs_layout = ctx.needs_layout();
2199 scene.runs
2200 }
2201
2202 fn run_until(&mut self, from_ms: u64, to_ms: u64) -> Vec<usize> {
2204 let mut painted = Vec::new();
2205 let mut t = from_ms;
2206 while t <= to_ms {
2207 painted = self.run(t);
2208 t += 16;
2209 }
2210 painted
2211 }
2212
2213 fn member_mains(&self) -> Vec<f64> {
2215 self.widget.members.iter().map(|p| p.size().width).collect()
2216 }
2217
2218 fn pointer(&mut self, phase: PointerPhase, x: f64) {
2219 let mut state = ();
2220 let size = self.size;
2221 dispatch(&mut self.widget, &mut state, size, &ev(phase, x, 16.0));
2222 }
2223 }
2224
2225 #[test]
2226 fn a_settled_squish_restores_every_member_to_its_natural_shape() {
2227 let view = button_group_actions(labelled_actions())
2228 .density(ButtonGroupDensity::Compact)
2229 .spacing(8.0)
2230 .size(ToggleButtonSize::Sm);
2231 let mut frames = Frames::new(&view, Size::new(1000.0, 200.0));
2232 let resting_runs = frames.run(0);
2233 let resting_mains = frames.member_mains();
2234 assert_eq!(resting_runs.len(), 3, "one label run per member");
2235
2236 let mut t = 0u64;
2240 for pressed in [1usize, 0] {
2241 let x = frames.widget.member_bases[pressed].x + 4.0;
2242 frames.pointer(PointerPhase::Down, x);
2243 let mid_squish = frames.run_until(t + 16, t + 16 * 4);
2244 assert_eq!(
2245 mid_squish, resting_runs,
2246 "a squished neighbour of {pressed} covers its label, never re-shapes it"
2247 );
2248 frames.run_until(t + 16 * 5, t + 16 * 60);
2249 frames.pointer(PointerPhase::Up, x);
2250 let settled_runs = frames.run_until(t + 16 * 61, t + 16 * 160);
2251 t += 16 * 160;
2252
2253 assert!(
2254 !frames.widget.squish.is_animating(),
2255 "the squish spring settled within the driven frames (pressed {pressed})"
2256 );
2257 assert_eq!(
2258 frames.widget.pressed_index, None,
2259 "the settled spring released member {pressed}"
2260 );
2261 assert_eq!(
2262 settled_runs, resting_runs,
2263 "every label paints its full glyph run again once member {pressed}'s squish settles"
2264 );
2265 assert_eq!(
2266 frames.member_mains(),
2267 resting_mains,
2268 "every member is back at its natural main extent after pressing {pressed}"
2269 );
2270 }
2271 }
2272
2273 #[test]
2274 fn reduce_motion_freezes_the_squish() {
2275 let mut theme = crate::baseline();
2276 theme.motion.reduce_motion = true;
2277 let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2278 let size = layout_themed(&mut widget, Size::new(500.0, 200.0), &theme);
2279 let mut state = ();
2280 dispatch(
2281 &mut widget,
2282 &mut state,
2283 size,
2284 &ev(PointerPhase::Down, 4.0, 20.0),
2285 );
2286 assert!(widget.squish.is_animating());
2287 paint(&mut widget, size, Some(&theme));
2288 assert!(
2289 !widget.squish.is_animating(),
2290 "reduce_motion stops the spring"
2291 );
2292 assert_eq!(widget.pressed_index, None);
2293 }
2294
2295 #[test]
2298 fn tap_reports_the_tapped_member_index() {
2299 let mut widget = build(&button_group::<u32, _>(
2300 vec!["A", "B", "C"],
2301 0,
2302 |s: &mut u32, i| *s = i as u32,
2303 ));
2304 let size = layout(&mut widget, Size::new(500.0, 200.0));
2305 let mid = widget.member_bases[1].x + 4.0;
2306 let mut state = 0u32;
2307 dispatch(
2308 &mut widget,
2309 &mut state,
2310 size,
2311 &ev(PointerPhase::Down, mid, 20.0),
2312 );
2313 assert_eq!(widget.pressed_index, Some(1));
2314 dispatch(
2315 &mut widget,
2316 &mut state,
2317 size,
2318 &ev(PointerPhase::Up, mid, 20.0),
2319 );
2320 assert_eq!(state, 1, "on_select fired with the tapped index");
2321 }
2322
2323 #[test]
2324 fn the_legacy_callback_still_fires_on_the_already_selected_member() {
2325 let mut widget = build(&button_group::<u32, _>(
2328 vec!["A", "B"],
2329 1,
2330 |s: &mut u32, i| *s = 40 + i as u32,
2331 ));
2332 let size = layout(&mut widget, Size::new(500.0, 200.0));
2333 let x = widget.member_bases[1].x + 4.0;
2334 let mut state = 0u32;
2335 dispatch(
2336 &mut widget,
2337 &mut state,
2338 size,
2339 &ev(PointerPhase::Down, x, 20.0),
2340 );
2341 dispatch(
2342 &mut widget,
2343 &mut state,
2344 size,
2345 &ev(PointerPhase::Up, x, 20.0),
2346 );
2347 assert_eq!(state, 41);
2348 }
2349
2350 #[test]
2351 fn single_select_reports_none_when_deselecting_the_current_member() {
2352 let seen: Rc<RefCell<Vec<Option<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2353 let sink = seen.clone();
2354 let view = button_group_actions(bare_icon_actions(2))
2355 .selected_index(Some(1))
2356 .on_selected_index_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2357 let mut widget = build(&view);
2358 let size = layout(&mut widget, Size::new(500.0, 200.0));
2359 let x = widget.member_bases[1].x + 4.0;
2360 let mut state = ();
2361 dispatch(
2362 &mut widget,
2363 &mut state,
2364 size,
2365 &ev(PointerPhase::Down, x, 20.0),
2366 );
2367 dispatch(
2368 &mut widget,
2369 &mut state,
2370 size,
2371 &ev(PointerPhase::Up, x, 20.0),
2372 );
2373 assert_eq!(*seen.borrow(), vec![None]);
2374 }
2375
2376 #[test]
2377 fn multi_select_reports_the_requested_index_set() {
2378 let seen: Rc<RefCell<Vec<Vec<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2379 let sink = seen.clone();
2380 let view = button_group_actions(bare_icon_actions(3))
2381 .selected_indices([2])
2382 .on_selected_indices_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2383 let mut widget = build(&view);
2384 let size = layout(&mut widget, Size::new(500.0, 200.0));
2385 let x = widget.member_bases[0].x + 4.0;
2386 let mut state = ();
2387 dispatch(
2388 &mut widget,
2389 &mut state,
2390 size,
2391 &ev(PointerPhase::Down, x, 20.0),
2392 );
2393 dispatch(
2394 &mut widget,
2395 &mut state,
2396 size,
2397 &ev(PointerPhase::Up, x, 20.0),
2398 );
2399 assert_eq!(*seen.borrow(), vec![vec![0, 2]], "added, kept ascending");
2400 }
2401
2402 #[test]
2403 fn multi_select_removes_an_already_selected_index() {
2404 let seen: Rc<RefCell<Vec<Vec<usize>>>> = Rc::new(RefCell::new(Vec::new()));
2405 let sink = seen.clone();
2406 let view = button_group_actions(bare_icon_actions(3))
2407 .selected_indices([0, 2])
2408 .on_selected_indices_changed(move |_s: &mut (), next| sink.borrow_mut().push(next));
2409 let mut widget = build(&view);
2410 let size = layout(&mut widget, Size::new(500.0, 200.0));
2411 let x = widget.member_bases[2].x + 4.0;
2412 let mut state = ();
2413 dispatch(
2414 &mut widget,
2415 &mut state,
2416 size,
2417 &ev(PointerPhase::Down, x, 20.0),
2418 );
2419 dispatch(
2420 &mut widget,
2421 &mut state,
2422 size,
2423 &ev(PointerPhase::Up, x, 20.0),
2424 );
2425 assert_eq!(*seen.borrow(), vec![vec![0]]);
2426 }
2427
2428 #[test]
2429 fn selection_resolution_follows_the_documented_precedence() {
2430 let view = button_group_actions(bare_icon_actions(3))
2432 .selected_index(Some(0))
2433 .selected_indices([1, 2]);
2434 assert!(!view.is_selected(0));
2435 assert!(view.is_selected(1) && view.is_selected(2));
2436
2437 let actions = vec![
2439 button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).checked(true),
2440 button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
2441 ];
2442 let view = button_group_actions(actions).selected_index(Some(1));
2443 assert!(!view.is_selected(0));
2444 assert!(view.is_selected(1));
2445
2446 let actions = vec![
2448 button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)).checked(true),
2449 button_group_icon_action::<(), _>(|| leaf_any(0.0, 0.0)),
2450 ];
2451 let view = button_group_actions(actions);
2452 assert!(view.is_selected(0));
2453 assert!(!view.is_selected(1));
2454 }
2455
2456 #[test]
2457 fn a_selected_index_outside_the_action_range_selects_nothing() {
2458 let view = button_group::<(), _>(vec!["A", "B"], 7, |_s, _i| {});
2459 assert!(!view.is_selected(0));
2460 assert!(!view.is_selected(1));
2461 }
2462
2463 #[test]
2464 fn is_controlled_the_view_never_self_mutates_its_selection() {
2465 let mut widget = build(&button_group::<u32, _>(vec!["A", "B"], 0, |_s, _i| {}));
2469 let size = layout(&mut widget, Size::new(500.0, 200.0));
2470 let x = widget.member_bases[1].x + 4.0;
2471 let mut state = 0u32;
2472 dispatch(
2473 &mut widget,
2474 &mut state,
2475 size,
2476 &ev(PointerPhase::Down, x, 20.0),
2477 );
2478 dispatch(
2479 &mut widget,
2480 &mut state,
2481 size,
2482 &ev(PointerPhase::Up, x, 20.0),
2483 );
2484
2485 let prev = button_group::<u32, _>(vec!["A", "B"], 0, |_s, _i| {});
2486 let next = button_group::<u32, _>(vec!["A", "B"], 1, |_s, _i| {});
2487 let mut counter = 0u64;
2488 let flags =
2489 View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
2490 assert!(!flags.is_empty(), "the selection flip reaches the members");
2491 }
2492
2493 #[test]
2494 fn a_release_outside_the_pressed_member_does_not_fire() {
2495 let mut widget = build(&button_group::<u32, _>(
2496 vec!["A", "B"],
2497 0,
2498 |s: &mut u32, i| *s = i as u32,
2499 ));
2500 let size = layout(&mut widget, Size::new(500.0, 200.0));
2501 let mut state = 7u32;
2502 dispatch(
2503 &mut widget,
2504 &mut state,
2505 size,
2506 &ev(PointerPhase::Down, 4.0, 20.0),
2507 );
2508 let away = widget.member_bases[1].x + 4.0;
2509 dispatch(
2510 &mut widget,
2511 &mut state,
2512 size,
2513 &ev(PointerPhase::Move, away, 20.0),
2514 );
2515 dispatch(
2516 &mut widget,
2517 &mut state,
2518 size,
2519 &ev(PointerPhase::Up, away, 20.0),
2520 );
2521 assert_eq!(state, 7, "the member's own up-inside rule still governs");
2522 }
2523
2524 #[test]
2525 fn a_down_outside_every_member_is_ignored() {
2526 let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2527 let size = layout(&mut widget, Size::new(500.0, 200.0));
2528 let mut state = ();
2529 let state_any: &mut dyn Any = &mut state;
2530 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2531 let result = widget.event(&mut ctx, &ev(PointerPhase::Down, 400.0, 20.0));
2532 assert!(matches!(result, EventResult::Ignored));
2533 assert_eq!(widget.pressed_index, None);
2534 }
2535
2536 #[test]
2537 fn cancel_clears_the_press_without_firing() {
2538 let mut widget = build(&button_group::<u32, _>(
2539 vec!["A", "B"],
2540 0,
2541 |s: &mut u32, i| *s = i as u32,
2542 ));
2543 let size = layout(&mut widget, Size::new(500.0, 200.0));
2544 let mut state = 3u32;
2545 dispatch(
2546 &mut widget,
2547 &mut state,
2548 size,
2549 &ev(PointerPhase::Down, 4.0, 20.0),
2550 );
2551 assert!(widget.active.is_some());
2552 dispatch(
2553 &mut widget,
2554 &mut state,
2555 size,
2556 &ev(PointerPhase::Cancel, 4.0, 20.0),
2557 );
2558 assert!(widget.active.is_none());
2559 assert_eq!(state, 3);
2560 }
2561
2562 #[test]
2565 fn overflow_none_lets_the_content_exceed_the_box() {
2566 let mut widget =
2567 build(&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::None));
2568 layout(&mut widget, Size::new(100.0, 200.0));
2569 assert_eq!(widget.visible_count, 6, "nothing is hidden");
2570 assert!(widget.content_main > widget.viewport_main);
2571 assert!(!widget.can_scroll(), "None never pans");
2572 }
2573
2574 #[test]
2575 fn overflow_scroll_pans_after_the_touch_slop() {
2576 let mut widget = build(&button_group_actions(bare_icon_actions(6)));
2577 let size = layout(&mut widget, Size::new(100.0, 200.0));
2578 assert!(widget.can_scroll());
2579 let start_x = widget.member_bases[0].x;
2580 let mut state = ();
2581 dispatch(
2582 &mut widget,
2583 &mut state,
2584 size,
2585 &ev(PointerPhase::Down, 4.0, 20.0),
2586 );
2587 dispatch(
2589 &mut widget,
2590 &mut state,
2591 size,
2592 &ev(PointerPhase::Move, 4.0, 20.0),
2593 );
2594 assert!(!widget.panning);
2595 assert_eq!(widget.scroll_offset, 0.0);
2596 let far = 4.0 - TOUCH_SLOP - 20.0;
2598 dispatch(
2599 &mut widget,
2600 &mut state,
2601 size,
2602 &ev(PointerPhase::Move, far, 20.0),
2603 );
2604 assert!(widget.panning);
2605 dispatch(
2606 &mut widget,
2607 &mut state,
2608 size,
2609 &ev(PointerPhase::Move, far - 15.0, 20.0),
2610 );
2611 assert!(widget.scroll_offset > 0.0, "the row panned");
2612 assert!(
2613 widget.members[0].origin().x < start_x,
2614 "member origins follow the pan"
2615 );
2616 dispatch(
2617 &mut widget,
2618 &mut state,
2619 size,
2620 &ev(PointerPhase::Up, far - 15.0, 20.0),
2621 );
2622 assert!(!widget.panning);
2623 }
2624
2625 #[test]
2626 fn overflow_scroll_clamps_the_pan_to_the_content() {
2627 let mut widget = build(&button_group_actions(bare_icon_actions(6)));
2628 layout(&mut widget, Size::new(100.0, 200.0));
2629 let max = widget.max_scroll();
2630 widget.pan_by(-10_000.0);
2631 assert_eq!(widget.scroll_offset, max);
2632 widget.pan_by(10_000.0);
2633 assert_eq!(widget.scroll_offset, 0.0);
2634 }
2635
2636 #[test]
2637 fn overflow_scroll_does_not_pan_when_the_content_fits() {
2638 let mut widget = build(&button_group_actions(bare_icon_actions(2)));
2639 layout(&mut widget, Size::new(500.0, 200.0));
2640 assert!(!widget.can_scroll());
2641 assert!(!widget.pan_by(-50.0));
2642 }
2643
2644 #[test]
2645 fn overflow_bottom_sheet_splits_and_shows_a_trigger() {
2646 let mut widget = build(
2647 &button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::BottomSheet),
2648 );
2649 layout(&mut widget, Size::new(140.0, 200.0));
2650 assert_eq!(widget.visible_count, 2);
2654 assert!(widget.trigger_visible);
2655 assert_eq!(widget.member_bases.len(), 2);
2656 }
2657
2658 #[test]
2659 fn overflow_bottom_sheet_hides_nothing_when_everything_fits() {
2660 let mut widget = build(
2661 &button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::BottomSheet),
2662 );
2663 layout(&mut widget, Size::new(500.0, 200.0));
2664 assert_eq!(widget.visible_count, 3);
2665 assert!(!widget.trigger_visible);
2666 }
2667
2668 #[test]
2669 fn tapping_the_overflow_trigger_reports_the_first_hidden_index() {
2670 let seen: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
2671 let sink = seen.clone();
2672 let view = button_group_actions(bare_icon_actions(6))
2673 .overflow(ButtonGroupOverflow::BottomSheet)
2674 .on_overflow(move |_s: &mut (), first| sink.borrow_mut().push(first));
2675 let mut widget = build(&view);
2676 let size = layout(&mut widget, Size::new(140.0, 200.0));
2677 let trigger_x = widget.trigger_base.x + 4.0;
2678 let mut state = ();
2679 dispatch(
2680 &mut widget,
2681 &mut state,
2682 size,
2683 &ev(PointerPhase::Down, trigger_x, 20.0),
2684 );
2685 assert_eq!(widget.active, Some(Target::Trigger));
2686 assert_eq!(widget.pressed_index, None, "the trigger never squishes");
2687 dispatch(
2688 &mut widget,
2689 &mut state,
2690 size,
2691 &ev(PointerPhase::Up, trigger_x, 20.0),
2692 );
2693 assert_eq!(*seen.borrow(), vec![2]);
2694 }
2695
2696 #[test]
2699 fn overflow_popup_group_builds_a_trigger_pod() {
2700 let view = button_group_actions(bare_icon_actions(3)).overflow(ButtonGroupOverflow::Popup);
2701 let widget = build(&view);
2702 assert!(widget.trigger.is_some());
2703 }
2704
2705 #[test]
2706 fn overflow_popup_splits_and_shows_a_trigger() {
2707 let mut widget =
2711 build(&button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::Popup));
2712 layout(&mut widget, Size::new(140.0, 200.0));
2713 assert_eq!(widget.visible_count, 2);
2714 assert!(widget.trigger_visible);
2715 }
2716
2717 #[test]
2718 fn the_trigger_captures_its_window_rect_into_the_overflow_anchor_when_wired() {
2719 let anchor = OverlayAnchor::new();
2720 let cell = anchor.clone();
2721 let mut root: frust_core::RenderRoot<(), ButtonGroupView<()>> =
2722 frust_core::RenderRoot::new();
2723 let mut state = ();
2724 let mut logic = move |_s: &mut ()| {
2725 button_group_actions(bare_icon_actions(6))
2726 .overflow(ButtonGroupOverflow::Popup)
2727 .overflow_anchor(&cell)
2728 };
2729 root.rebuild(&mut logic, &mut state);
2730 let mut tcx = TextContext::new();
2731 root.layout_with_text(Size::new(140.0, 200.0), &mut tcx as &mut dyn Any);
2732 let mut scene = NullScene;
2733 root.paint(&mut scene, FrameTime::ZERO);
2734 let rect = anchor.rect();
2735 assert!(
2736 rect.width() > 0.0 && rect.height() > 0.0,
2737 "the trigger's window rect was captured into the overflow anchor on paint: {rect:?}"
2738 );
2739 }
2740
2741 #[derive(Default)]
2742 struct OverflowMenuState {
2743 selected: Option<usize>,
2744 }
2745
2746 fn overflow_menu_actions() -> Vec<ButtonGroupAction<OverflowMenuState>> {
2747 (0..6)
2748 .map(|i| button_group_action(format!("Item {i}")))
2749 .collect()
2750 }
2751
2752 #[test]
2753 fn overflow_menu_shows_hidden_members_checked_state_per_upstream() {
2754 let anchor = OverlayAnchor::new();
2761 anchor.set(Rect::new(40.0, 100.0, 160.0, 140.0));
2762 let cell = anchor.clone();
2763 let mut root: frust_core::RenderRoot<
2764 OverflowMenuState,
2765 frust::StackView<OverflowMenuState>,
2766 > = frust_core::RenderRoot::new();
2767 let mut state = OverflowMenuState { selected: Some(4) };
2768 let mut logic = move |s: &mut OverflowMenuState| {
2769 let group = button_group_actions(overflow_menu_actions())
2770 .overflow(ButtonGroupOverflow::Popup)
2771 .overflow_anchor(&cell)
2772 .selected_index(s.selected);
2773 frust::Stack(vec![group.overflow_menu(4, true)])
2777 };
2778 root.rebuild(&mut logic, &mut state);
2779 let mut tcx = TextContext::new();
2780 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2781 let update = root.semantics();
2782
2783 let boxes: Vec<_> = update
2784 .nodes
2785 .iter()
2786 .filter(|(_, n)| n.role() == Role::MenuItemCheckBox)
2787 .collect();
2788 assert_eq!(boxes.len(), 2, "only the two hidden members (indices 4, 5)");
2789 let checked = boxes
2790 .iter()
2791 .find(|(_, n)| n.label() == Some("Item 4"))
2792 .expect("Item 4 is present");
2793 assert_eq!(
2794 checked.1.toggled(),
2795 Some(frust::authoring::Toggled::True),
2796 "the group's own selected index shows checked in the popup"
2797 );
2798 let unchecked = boxes
2799 .iter()
2800 .find(|(_, n)| n.label() == Some("Item 5"))
2801 .expect("Item 5 is present");
2802 assert_eq!(
2803 unchecked.1.toggled(),
2804 Some(frust::authoring::Toggled::False)
2805 );
2806 }
2807
2808 #[test]
2809 fn overflow_menu_selection_routes_through_the_groups_own_fan_out() {
2810 let anchor_rect = Rect::new(40.0, 100.0, 160.0, 140.0);
2814 let anchor = OverlayAnchor::new();
2815 anchor.set(anchor_rect);
2816 let cell = anchor.clone();
2817 let mut root: frust_core::RenderRoot<
2818 OverflowMenuState,
2819 frust::StackView<OverflowMenuState>,
2820 > = frust_core::RenderRoot::new();
2821 let mut state = OverflowMenuState { selected: None };
2822 let mut logic = move |s: &mut OverflowMenuState| {
2823 let group = button_group_actions(overflow_menu_actions())
2824 .overflow(ButtonGroupOverflow::Popup)
2825 .overflow_anchor(&cell)
2826 .selected_index(s.selected)
2827 .on_selected_index_changed(|s: &mut OverflowMenuState, i| s.selected = i);
2828 frust::Stack(vec![group.overflow_menu(5, true)])
2829 };
2830 root.rebuild(&mut logic, &mut state);
2831 let mut tcx = TextContext::new();
2832 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2833
2834 let row = Point::new(
2835 anchor_rect.x0 + crate::menu::MENU_MIN_WIDTH / 2.0,
2836 anchor_rect.y1
2837 + crate::overlay::OVERLAY_ANCHOR_GAP
2838 + crate::menu::MENU_SURFACE_V_PADDING
2839 + (crate::menu::MENU_ITEM_GAP + crate::menu::MENU_ROW_MIN_HEIGHT) / 2.0,
2840 );
2841 root.event(&mut state, &ev(PointerPhase::Down, row.x, row.y));
2842 root.event(&mut state, &ev(PointerPhase::Up, row.x, row.y));
2843
2844 assert_eq!(
2845 state.selected,
2846 Some(5),
2847 "activating the only hidden member's popup row reports through \
2848 `on_selected_index_changed`, exactly as a visible member's own tap does"
2849 );
2850 }
2851
2852 #[test]
2855 fn semantics_yields_a_group_of_toggle_members() {
2856 fn logic(_s: &mut ()) -> ButtonGroupView<()> {
2860 button_group::<(), _>(vec!["One", "Two", "Three"], 2, |_s: &mut (), _i| {})
2861 .semantic_label("Range")
2862 }
2863 let mut root: frust_core::RenderRoot<(), ButtonGroupView<()>> =
2864 frust_core::RenderRoot::new();
2865 let mut state = ();
2866 root.rebuild(&mut logic, &mut state);
2867 let mut tcx = TextContext::new();
2868 root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
2869 let update = root.semantics();
2870
2871 let group = update
2872 .nodes
2873 .iter()
2874 .find(|(_, n)| n.role() == Role::Group)
2875 .expect("a Group container node is contributed");
2876 assert_eq!(group.1.children().len(), 3);
2877 assert_eq!(group.1.label(), Some("Range"));
2878
2879 let members: Vec<_> = update
2880 .nodes
2881 .iter()
2882 .filter(|(_, n)| n.role() == Role::Button)
2883 .collect();
2884 assert_eq!(members.len(), 3);
2885 let selected = members
2886 .iter()
2887 .find(|(_, n)| n.label() == Some("Three"))
2888 .expect("the Three member is present");
2889 assert_eq!(selected.1.toggled(), Some(frust::authoring::Toggled::True));
2890 let unselected = members
2891 .iter()
2892 .find(|(_, n)| n.label() == Some("One"))
2893 .expect("the One member is present");
2894 assert_eq!(
2895 unselected.1.toggled(),
2896 Some(frust::authoring::Toggled::False)
2897 );
2898 }
2899
2900 #[test]
2901 fn semantics_omit_the_members_the_overflow_split_hid() {
2902 let mut widget = build(
2903 &button_group_actions(bare_icon_actions(6)).overflow(ButtonGroupOverflow::BottomSheet),
2904 );
2905 layout(&mut widget, Size::new(140.0, 200.0));
2906 assert_eq!(widget.visible_count, 2);
2907 assert!(widget.hit(Point::new(300.0, 20.0)).is_none());
2910 }
2911
2912 #[test]
2915 fn this_module_no_longer_uses_the_legacy_radial_shape_model() {
2916 let src = include_str!("button_group.rs");
2919 let import = format!("use super::{}_morph", "shape");
2920 let call = format!("{}_path(", "morph");
2921 assert!(
2922 !src.contains(&import),
2923 "the legacy radial polygon module must not be imported here"
2924 );
2925 assert!(
2926 !src.contains(&call),
2927 "the legacy radial morph primitive must not be called here"
2928 );
2929 }
2930}