1pub(crate) mod layout;
100pub(crate) mod physics;
101
102use std::rc::Rc;
103
104use frust::authoring::{
105 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
106 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
107 PointerPhase, Role, ScrollDelta, SemanticsCtx, TypedArgCallback, View, Widget,
108};
109use frust::input::{TOUCH_SLOP, VelocityTracker, WHEEL_LINE_PX};
110use frust::{AnimationController, FrameTime, Theme};
111use kurbo::{Point, Rect, Size};
112use peniko::Color;
113
114use self::layout::{
115 CONTAINED_EXTENDED_WEIGHTS, CONTAINED_WEIGHTS, FixedLayout, HERO_CENTER_WEIGHTS,
116 HERO_END_WEIGHTS, HERO_START_WEIGHTS, Slot, WeightedLayout,
117};
118use crate::press::presses;
119use crate::state_layer::StateLayer;
120
121pub const CAROUSEL_ITEM_PADDING: f64 = 4.0;
125
126pub const CAROUSEL_ITEM_RADIUS: f64 = 28.0;
130
131pub const UNCONTAINED_ITEM_EXTENT: f64 = 270.0;
134
135pub const UNCONTAINED_SHRINK_EXTENT: f64 = 150.0;
139
140const OFFSET_EPSILON: f64 = 1e-3;
142
143const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
146
147#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
149pub enum CarouselLayout {
150 #[default]
153 Hero,
154 Contained,
157 Uncontained,
160}
161
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169pub enum HeroAlignment {
170 Start,
172 #[default]
174 Center,
175 End,
177}
178
179#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
181pub enum CarouselAxis {
182 #[default]
184 Horizontal,
185 Vertical,
187}
188
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub struct CarouselChange {
193 pub leading_index: usize,
195 pub focal_index: usize,
199 pub item_count: usize,
201}
202
203impl CarouselChange {
204 pub fn is_focal(&self, index: usize) -> bool {
206 index == self.focal_index
207 }
208}
209
210pub struct CarouselView<State: 'static> {
212 items: Vec<AnyView<State>>,
213 layout: CarouselLayout,
214 hero_alignment: HeroAlignment,
215 extended: bool,
216 axis: CarouselAxis,
217 item_extent: f64,
218 shrink_extent: Option<f64>,
219 item_radius: Option<f64>,
220 width: Option<f64>,
221 height: Option<f64>,
222 consume_max_weight: bool,
223 initial_item: usize,
224 selected: Option<usize>,
225 on_change: Option<TypedArgCallback<State, CarouselChange>>,
226 on_tap: Option<TypedArgCallback<State, usize>>,
227}
228
229pub fn carousel<State: 'static>(items: Vec<AnyView<State>>) -> CarouselView<State> {
232 CarouselView {
233 items,
234 layout: CarouselLayout::default(),
235 hero_alignment: HeroAlignment::default(),
236 extended: false,
237 axis: CarouselAxis::default(),
238 item_extent: UNCONTAINED_ITEM_EXTENT,
239 shrink_extent: None,
240 item_radius: None,
241 width: None,
242 height: None,
243 consume_max_weight: false,
244 initial_item: 0,
245 selected: None,
246 on_change: None,
247 on_tap: None,
248 }
249}
250
251pub fn hero_carousel<State: 'static>(
254 items: Vec<AnyView<State>>,
255 alignment: HeroAlignment,
256) -> CarouselView<State> {
257 carousel(items).hero_alignment(alignment)
258}
259
260pub fn contained_carousel<State: 'static>(items: Vec<AnyView<State>>) -> CarouselView<State> {
263 carousel(items).layout(CarouselLayout::Contained)
264}
265
266pub fn uncontained_carousel<State: 'static>(
269 items: Vec<AnyView<State>>,
270 item_extent: f64,
271) -> CarouselView<State> {
272 carousel(items)
273 .layout(CarouselLayout::Uncontained)
274 .item_extent(item_extent)
275}
276
277impl<State: 'static> CarouselView<State> {
278 pub fn layout(mut self, layout: CarouselLayout) -> Self {
280 self.layout = layout;
281 self
282 }
283
284 pub fn hero_alignment(mut self, alignment: HeroAlignment) -> Self {
287 self.hero_alignment = alignment;
288 self
289 }
290
291 pub fn extended(mut self, extended: bool) -> Self {
294 self.extended = extended;
295 self
296 }
297
298 pub fn axis(mut self, axis: CarouselAxis) -> Self {
300 self.axis = axis;
301 self
302 }
303
304 pub fn item_extent(mut self, extent: f64) -> Self {
309 self.item_extent = extent;
310 self
311 }
312
313 pub fn shrink_extent(mut self, extent: f64) -> Self {
319 self.shrink_extent = Some(extent);
320 self
321 }
322
323 pub fn item_radius(mut self, radius: f64) -> Self {
327 self.item_radius = Some(radius);
328 self
329 }
330
331 pub fn width(mut self, width: f64) -> Self {
334 self.width = Some(width);
335 self
336 }
337
338 pub fn height(mut self, height: f64) -> Self {
341 self.height = Some(height);
342 self
343 }
344
345 pub fn consume_max_weight(mut self, consume: bool) -> Self {
352 self.consume_max_weight = consume;
353 self
354 }
355
356 pub fn initial_item(mut self, index: usize) -> Self {
360 self.initial_item = index;
361 self
362 }
363
364 pub fn selected(mut self, focal_index: usize) -> Self {
369 self.selected = Some(focal_index);
370 self
371 }
372
373 pub fn on_change<F: Fn(&mut State, CarouselChange) + 'static>(mut self, on_change: F) -> Self {
375 self.on_change = Some(Rc::new(on_change));
376 self
377 }
378
379 pub fn on_tap<F: Fn(&mut State, usize) + 'static>(mut self, on_tap: F) -> Self {
381 self.on_tap = Some(Rc::new(on_tap));
382 self
383 }
384
385 fn sizing(&self) -> Sizing {
387 match self.layout {
388 CarouselLayout::Hero => Sizing::Weighted(match self.hero_alignment {
389 HeroAlignment::Start => HERO_START_WEIGHTS,
390 HeroAlignment::Center => HERO_CENTER_WEIGHTS,
391 HeroAlignment::End => HERO_END_WEIGHTS,
392 }),
393 CarouselLayout::Contained => Sizing::Weighted(if self.extended {
394 CONTAINED_EXTENDED_WEIGHTS
395 } else {
396 CONTAINED_WEIGHTS
397 }),
398 CarouselLayout::Uncontained => Sizing::Fixed {
399 item_extent: self.item_extent,
400 },
401 }
402 }
403
404 fn resolved_shrink_extent(&self) -> f64 {
407 self.shrink_extent.unwrap_or(match self.layout {
408 CarouselLayout::Uncontained => UNCONTAINED_SHRINK_EXTENT,
409 _ => 0.0,
410 })
411 }
412}
413
414impl<State: 'static> View<State> for CarouselView<State> {
415 type Element = CarouselWidget;
416
417 fn build(&self, ctx: &mut BuildCtx<'_>) -> CarouselWidget {
418 let sizing = self.sizing();
419 let mut widget = CarouselWidget {
420 pods: self
421 .items
422 .iter()
423 .map(|item| frust::authoring::build_child(item, ctx))
424 .collect(),
425 sizing,
426 axis: self.axis,
427 shrink_extent: self.resolved_shrink_extent(),
428 item_radius: self.item_radius,
429 width: self.width,
430 height: self.height,
431 consume_max_weight: self.consume_max_weight,
432 controlled: self.selected.is_some(),
433 index: 0,
434 offset: 0.0,
435 settle: physics::settle_controller(),
436 settle_from: 0.0,
437 settle_to: 0.0,
438 main: 0.0,
439 cross: 0.0,
440 slots: Vec::new(),
441 drag: None,
442 pressed: None,
443 state: StateLayer::new(),
444 tracker: VelocityTracker::new(),
445 last_frame_time: FrameTime::ZERO,
446 reported: (0, 0),
447 on_change: self
448 .on_change
449 .as_ref()
450 .map(frust::authoring::erase_callback_arg),
451 on_tap: self
452 .on_tap
453 .as_ref()
454 .map(frust::authoring::erase_callback_arg),
455 };
456 widget.index = widget.leading_for_focal(self.selected.unwrap_or(self.initial_item));
457 widget.reported = widget.indices_for_leading(widget.index);
458 widget
459 }
460
461 fn rebuild(
462 &self,
463 prev: &Self,
464 element: &mut CarouselWidget,
465 ctx: &mut BuildCtx<'_>,
466 ) -> ChangeFlags {
467 let mut flags = frust::authoring::rebuild_children(
468 &prev.items,
469 &self.items,
470 &mut element.pods,
471 ctx,
472 |view: &AnyView<State>| view,
473 |_| None,
474 );
475 let sizing = self.sizing();
476 let shrink_extent = self.resolved_shrink_extent();
477 if element.sizing != sizing
478 || element.axis != self.axis
479 || element.shrink_extent != shrink_extent
480 || element.width != self.width
481 || element.height != self.height
482 || element.consume_max_weight != self.consume_max_weight
483 {
484 element.sizing = sizing;
485 element.axis = self.axis;
486 element.shrink_extent = shrink_extent;
487 element.width = self.width;
488 element.height = self.height;
489 element.consume_max_weight = self.consume_max_weight;
490 element.settle.stop();
494 element.offset = element.offset_for(element.index);
495 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
496 }
497 if element.item_radius != self.item_radius {
498 element.item_radius = self.item_radius;
499 flags |= ChangeFlags::PAINT;
500 }
501 element.controlled = self.selected.is_some();
502 let last = element.pods.len().saturating_sub(1);
503 if let Some(selected) = self.selected {
504 let index = element.leading_for_focal(selected);
507 if element.index != index {
508 element.index = index;
509 element.reported = element.indices_for_leading(index);
510 element.start_settle();
511 flags |= ChangeFlags::PAINT;
512 }
513 } else if element.index > last {
514 element.index = last;
516 element.reported = element.indices_for_leading(last);
517 element.start_settle();
518 flags |= ChangeFlags::PAINT;
519 }
520 element.on_change = self
521 .on_change
522 .as_ref()
523 .map(frust::authoring::erase_callback_arg);
524 element.on_tap = self
525 .on_tap
526 .as_ref()
527 .map(frust::authoring::erase_callback_arg);
528 flags
529 }
530
531 fn teardown(&self, element: &mut CarouselWidget, ctx: &mut BuildCtx<'_>) {
532 for (view, pod) in self.items.iter().zip(element.pods.iter_mut()) {
533 frust::authoring::teardown_child(view, pod, ctx);
534 }
535 }
536}
537
538#[derive(Clone, Copy, Debug, PartialEq)]
541enum Sizing {
542 Weighted(&'static [u16]),
544 Fixed { item_extent: f64 },
546}
547
548enum Solver {
551 Weighted(WeightedLayout<'static>),
552 Fixed(FixedLayout),
553}
554
555impl Solver {
556 fn slots(&self, item_count: usize) -> Vec<Slot> {
558 match self {
559 Solver::Weighted(l) => l.slots(item_count),
560 Solver::Fixed(l) => l.slots(item_count),
561 }
562 }
563
564 fn stride(&self) -> f64 {
566 match self {
567 Solver::Weighted(l) => l.first_child_extent(),
568 Solver::Fixed(l) => l.stride(),
569 }
570 }
571
572 fn slot_extent(&self) -> f64 {
575 match self {
576 Solver::Weighted(l) => l.max_child_extent(),
577 Solver::Fixed(l) => l.stride(),
578 }
579 }
580
581 fn max_scroll_offset(&self, item_count: usize) -> f64 {
583 match self {
584 Solver::Weighted(l) => l.max_scroll_offset(item_count),
585 Solver::Fixed(l) => l.max_scroll_offset(item_count),
586 }
587 }
588}
589
590struct Drag {
592 start: f64,
594 from: f64,
596 item: Option<usize>,
598 scrolling: bool,
601}
602
603pub struct CarouselWidget {
605 pods: Vec<ChildPod>,
606 sizing: Sizing,
607 axis: CarouselAxis,
608 shrink_extent: f64,
609 item_radius: Option<f64>,
610 width: Option<f64>,
611 height: Option<f64>,
612 consume_max_weight: bool,
613 controlled: bool,
615 index: usize,
620 offset: f64,
622 settle: AnimationController,
623 settle_from: f64,
625 settle_to: f64,
626 main: f64,
628 cross: f64,
629 slots: Vec<Slot>,
631 drag: Option<Drag>,
632 pressed: Option<usize>,
634 state: StateLayer,
635 tracker: VelocityTracker,
636 last_frame_time: FrameTime,
639 reported: (usize, usize),
641 on_change: Option<ErasedArgCallback<CarouselChange>>,
642 on_tap: Option<ErasedArgCallback<usize>>,
643}
644
645impl CarouselWidget {
646 pub fn leading_index(&self) -> usize {
649 self.indices_for_leading(self.index).0
650 }
651
652 pub fn focal_index(&self) -> usize {
654 self.indices_for_leading(self.index).1
655 }
656
657 pub fn offset(&self) -> f64 {
659 self.offset
660 }
661
662 fn solver(&self, offset: f64) -> Solver {
664 match self.sizing {
665 Sizing::Weighted(weights) => Solver::Weighted(WeightedLayout::new(
666 weights,
667 self.main,
668 self.shrink_extent,
669 offset,
670 self.consume_max_weight,
671 )),
672 Sizing::Fixed { item_extent } => Solver::Fixed(FixedLayout::new(
673 item_extent,
674 self.shrink_extent,
675 self.main,
676 offset,
677 )),
678 }
679 }
680
681 fn focal_slot(&self) -> usize {
685 match self.sizing {
686 Sizing::Weighted(weights) => {
687 let max = weights.iter().copied().max().unwrap_or(0);
688 weights.iter().position(|w| *w == max).unwrap_or(0)
689 }
690 Sizing::Fixed { .. } => 0,
691 }
692 }
693
694 fn leading_for_focal(&self, focal: usize) -> usize {
697 let last = self.pods.len().saturating_sub(1);
698 let raw = if self.consume_max_weight {
699 focal
700 } else {
701 focal.saturating_sub(self.focal_slot())
702 };
703 raw.min(last)
704 }
705
706 fn indices_for_leading(&self, raw: usize) -> (usize, usize) {
716 let last = self.pods.len().saturating_sub(1);
717 let raw = raw.min(last);
718 if self.consume_max_weight {
719 (raw.saturating_sub(self.focal_slot()), raw)
720 } else {
721 (raw, (raw + self.focal_slot()).min(last))
722 }
723 }
724
725 fn indices_at(&self, offset: f64) -> (usize, usize) {
729 let raw = physics::nearest_item(offset, self.solver(offset).stride());
730 self.indices_for_leading(raw)
731 }
732
733 fn offset_for(&self, index: usize) -> f64 {
735 let solver = self.solver(self.offset);
736 let target = index as f64 * solver.stride();
737 target
740 .max(0.0)
741 .min(solver.max_scroll_offset(self.pods.len()).max(0.0))
742 }
743
744 fn max_offset(&self) -> f64 {
746 self.solver(self.offset)
747 .max_scroll_offset(self.pods.len())
748 .max(0.0)
749 }
750
751 fn scrollable(&self) -> bool {
753 self.max_offset() > 0.0
754 }
755
756 fn set_offset(&mut self, offset: f64) {
758 let offset = if offset.is_finite() { offset } else { 0.0 };
759 self.offset = offset.max(0.0).min(self.max_offset());
761 self.sync_geometry();
762 }
763
764 fn sync_geometry(&mut self) {
769 self.slots = self.solver(self.offset).slots(self.pods.len());
770 let content = self.content_size();
771 let first_visible = self.slots.first().map(|s| s.index);
772 for (index, pod) in self.pods.iter_mut().enumerate() {
773 let origin = match self.slots.iter().find(|s| s.index == index) {
774 Some(slot) => {
775 let along =
779 slot.offset + (slot.extent - content_main(self.axis, content)) / 2.0;
780 let across = (self.cross - content_cross(self.axis, content)) / 2.0;
781 axis_point(self.axis, along, across)
782 }
783 None => {
784 let along = if first_visible.is_some_and(|first| index < first) {
788 -content_main(self.axis, content) - 1.0
789 } else {
790 self.main + 1.0
791 };
792 axis_point(self.axis, along, 0.0)
793 }
794 };
795 pod.set_origin(origin);
796 }
797 }
798
799 fn content_size(&self) -> Size {
802 let slot = self.solver(self.offset).slot_extent();
803 let main = (slot - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
804 let cross = (self.cross - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
805 match self.axis {
806 CarouselAxis::Horizontal => Size::new(main, cross),
807 CarouselAxis::Vertical => Size::new(cross, main),
808 }
809 }
810
811 fn item_rect(&self, slot: &Slot) -> Rect {
814 let main = (slot.extent - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
815 let cross = (self.cross - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
816 let origin = axis_point(
817 self.axis,
818 slot.offset + CAROUSEL_ITEM_PADDING,
819 CAROUSEL_ITEM_PADDING,
820 );
821 let size = match self.axis {
822 CarouselAxis::Horizontal => Size::new(main, cross),
823 CarouselAxis::Vertical => Size::new(cross, main),
824 };
825 Rect::from_origin_size(origin, size)
826 }
827
828 fn item_at(&self, pos: Point) -> Option<usize> {
830 self.slots
831 .iter()
832 .find(|slot| self.item_rect(slot).contains(pos))
833 .map(|slot| slot.index)
834 }
835
836 fn main_of(&self, point: Point) -> f64 {
838 match self.axis {
839 CarouselAxis::Horizontal => point.x,
840 CarouselAxis::Vertical => point.y,
841 }
842 }
843
844 fn event_time_ms(&self) -> f64 {
847 self.last_frame_time.as_secs_f64() * 1000.0
848 }
849
850 fn settle_to(&mut self, target: f64) {
852 self.settle_from = self.offset;
853 self.settle_to = target;
854 self.settle = physics::settle_controller();
855 self.settle.forward();
856 }
857
858 fn start_settle(&mut self) {
860 let target = self.offset_for(self.index);
861 self.settle_to(target);
862 }
863
864 fn report(&mut self, ctx: &mut EventCtx, offset: f64) {
867 let pair = self.indices_at(offset);
868 if pair == self.reported {
869 return;
870 }
871 self.reported = pair;
872 let count = self.pods.len();
873 if let Some(on_change) = self.on_change.as_mut() {
874 on_change(
875 ctx,
876 CarouselChange {
877 leading_index: pair.0,
878 focal_index: pair.1,
879 item_count: count,
880 },
881 );
882 }
883 }
884
885 fn cancel_item(&mut self, ctx: &mut EventCtx, item: usize, pos: Point) {
888 if let Some(pod) = self.pods.get_mut(item) {
889 let cancel = InputEvent::Pointer(PointerEvent {
890 phase: PointerPhase::Cancel,
891 position: pos,
892 button: PointerButton::Primary,
893 });
894 pod.event_child(ctx, &cancel);
895 pod.set_active(false);
896 }
897 }
898
899 fn blur_unhit(&mut self, hit: Option<usize>) {
905 let kept = hit.filter(|index| self.pods.get(*index).is_some_and(ChildPod::is_focused));
906 for (index, pod) in self.pods.iter_mut().enumerate() {
907 if Some(index) != kept && pod.is_focused() {
908 pod.set_focused(false);
909 }
910 }
911 }
912
913 fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
917 if event.is_broadcast() {
920 return frust::authoring::route_event(&mut self.pods, ctx, event);
921 }
922 if let InputEvent::Scroll { delta, .. } = event
923 && self.drag.is_none()
924 && self.scrollable()
925 {
926 return self.wheel(ctx, delta);
927 }
928 let InputEvent::Pointer(p) = event else {
929 return frust::authoring::route_event(&mut self.pods, ctx, event);
931 };
932
933 if self.drag.is_some() {
935 return self.drag_event(ctx, event, p, t_ms);
936 }
937
938 match p.phase {
939 PointerPhase::Down => {
940 let item = self.item_at(p.position);
941 if !presses(p) {
942 let result = self.forward_to_item(ctx, event, item);
945 self.blur_unhit(item);
946 return result;
947 }
948 let main = self.main_of(p.position);
949 self.drag = Some(Drag {
950 start: main,
951 from: self.offset,
952 item,
953 scrolling: false,
954 });
955 self.tracker.clear();
956 self.tracker.record(t_ms, main);
957 self.settle.stop();
958 self.pressed = item;
959 self.state.set_pressed(item.is_some());
960 ctx.capture_pointer();
961 self.forward_to_item(ctx, event, item);
962 self.blur_unhit(item);
963 ctx.request_redraw();
964 EventResult::Handled
965 }
966 PointerPhase::Move => {
967 self.forward_to_item(ctx, event, self.item_at(p.position));
970 EventResult::Ignored
971 }
972 PointerPhase::Up | PointerPhase::Cancel => EventResult::Ignored,
973 }
974 }
975
976 fn drag_event(
979 &mut self,
980 ctx: &mut EventCtx,
981 event: &InputEvent,
982 p: &PointerEvent,
983 t_ms: f64,
984 ) -> EventResult {
985 let main = self.main_of(p.position);
986 match p.phase {
987 PointerPhase::Down => EventResult::Handled,
988 PointerPhase::Move => {
989 self.tracker.record(t_ms, main);
990 let Some(drag) = self.drag.as_ref() else {
991 return EventResult::Handled;
992 };
993 let (from, start, item, scrolling) =
994 (drag.from, drag.start, drag.item, drag.scrolling);
995 if scrolling {
996 self.set_offset(from - (main - start));
997 self.report(ctx, self.offset);
998 ctx.request_redraw();
999 return EventResult::Handled;
1000 }
1001 if self.scrollable() && (main - start).abs() > TOUCH_SLOP {
1002 if let Some(item) = item {
1005 self.cancel_item(ctx, item, p.position);
1006 }
1007 self.pressed = None;
1008 self.state.set_pressed(false);
1009 if let Some(drag) = self.drag.as_mut() {
1010 drag.scrolling = true;
1011 }
1012 ctx.request_redraw();
1013 return EventResult::Handled;
1014 }
1015 self.forward_to_item(ctx, event, item);
1016 EventResult::Handled
1017 }
1018 PointerPhase::Up => {
1019 let Some(drag) = self.drag.take() else {
1020 return EventResult::Handled;
1021 };
1022 self.pressed = None;
1023 self.state.set_pressed(false);
1024 if drag.scrolling {
1025 self.release(ctx);
1026 } else {
1027 self.forward_to_item(ctx, event, drag.item);
1028 if let Some(item) = drag.item
1031 && self.item_at(p.position) == Some(item)
1032 && let Some(on_tap) = self.on_tap.as_mut()
1033 {
1034 on_tap(ctx, item);
1035 }
1036 }
1037 if let Some(item) = drag.item
1038 && let Some(pod) = self.pods.get_mut(item)
1039 {
1040 pod.set_active(false);
1041 }
1042 ctx.request_redraw();
1043 EventResult::Handled
1044 }
1045 PointerPhase::Cancel => {
1046 let drag = self.drag.take();
1049 self.pressed = None;
1050 self.state.set_pressed(false);
1051 if let Some(drag) = &drag
1052 && let Some(item) = drag.item
1053 {
1054 if !drag.scrolling {
1055 self.forward_to_item(ctx, event, Some(item));
1056 }
1057 if let Some(pod) = self.pods.get_mut(item) {
1058 pod.set_active(false);
1059 }
1060 }
1061 self.start_settle();
1062 ctx.request_redraw();
1063 EventResult::Handled
1064 }
1065 }
1066 }
1067
1068 fn release(&mut self, ctx: &mut EventCtx) {
1072 let scroll_velocity = -self.tracker.velocity();
1075 self.settle_snapped(ctx, scroll_velocity);
1076 }
1077
1078 fn settle_snapped(&mut self, ctx: &mut EventCtx, scroll_velocity: f64) {
1080 let stride = self.solver(self.offset).stride();
1081 let target = physics::snap_offset(self.offset, stride, scroll_velocity, self.max_offset());
1082 self.report(ctx, target);
1083 if self.controlled {
1084 self.start_settle();
1087 return;
1088 }
1089 self.index = physics::nearest_item(target, stride).min(self.pods.len().saturating_sub(1));
1090 self.settle_to(target);
1091 }
1092
1093 fn wheel(&mut self, ctx: &mut EventCtx, delta: &ScrollDelta) -> EventResult {
1102 let (dx, dy) = match delta {
1103 ScrollDelta::Lines(x, y) => (x * WHEEL_LINE_PX, y * WHEEL_LINE_PX),
1104 ScrollDelta::Pixels(x, y) => (*x, *y),
1105 };
1106 let main = match self.axis {
1110 CarouselAxis::Horizontal if dx != 0.0 => dx,
1111 CarouselAxis::Horizontal | CarouselAxis::Vertical => dy,
1112 };
1113 self.set_offset(self.offset + main);
1114 self.settle_snapped(ctx, 0.0);
1117 ctx.request_redraw();
1118 EventResult::Handled
1119 }
1120
1121 fn forward_to_item(
1123 &mut self,
1124 ctx: &mut EventCtx,
1125 event: &InputEvent,
1126 item: Option<usize>,
1127 ) -> EventResult {
1128 match item.and_then(|index| self.pods.get_mut(index)) {
1129 Some(pod) => pod.event_child(ctx, event),
1130 None => EventResult::Ignored,
1131 }
1132 }
1133
1134 fn advance_settle(&mut self, ctx: &mut PaintCtx, reduce_motion: bool) {
1137 if self.drag.is_some() || !self.settle.is_animating() {
1138 return;
1139 }
1140 let next = if reduce_motion {
1141 self.settle.stop();
1142 self.settle_to
1143 } else {
1144 if self.settle.advance(ctx.frame_time()) {
1145 ctx.request_frame();
1146 }
1147 let ramp = self.settle.value_clamped();
1148 self.settle_from + (self.settle_to - self.settle_from) * ramp
1149 };
1150 if (next - self.offset).abs() > OFFSET_EPSILON {
1151 self.set_offset(next);
1152 }
1153 }
1154}
1155
1156fn content_main(axis: CarouselAxis, size: Size) -> f64 {
1158 match axis {
1159 CarouselAxis::Horizontal => size.width,
1160 CarouselAxis::Vertical => size.height,
1161 }
1162}
1163
1164fn content_cross(axis: CarouselAxis, size: Size) -> f64 {
1166 match axis {
1167 CarouselAxis::Horizontal => size.height,
1168 CarouselAxis::Vertical => size.width,
1169 }
1170}
1171
1172fn axis_point(axis: CarouselAxis, main: f64, cross: f64) -> Point {
1175 match axis {
1176 CarouselAxis::Horizontal => Point::new(main, cross),
1177 CarouselAxis::Vertical => Point::new(cross, main),
1178 }
1179}
1180
1181fn resolve_radius(explicit: Option<f64>, theme: Option<&Theme>) -> f64 {
1184 explicit.unwrap_or(match theme {
1185 Some(theme) => theme.shape.extra_large,
1186 None => CAROUSEL_ITEM_RADIUS,
1187 })
1188}
1189
1190fn resolve_content_color(theme: Option<&Theme>) -> Color {
1193 match theme {
1194 Some(theme) => theme.scheme().on_surface,
1195 None => ON_SURFACE,
1196 }
1197}
1198
1199impl Widget for CarouselWidget {
1200 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1201 let max = bc.max();
1202 let width = self
1207 .width
1208 .unwrap_or(if max.width.is_finite() {
1209 max.width
1210 } else {
1211 0.0
1212 })
1213 .max(0.0);
1214 let height = self
1215 .height
1216 .unwrap_or(if max.height.is_finite() {
1217 max.height
1218 } else {
1219 0.0
1220 })
1221 .max(0.0);
1222 let size = Size::new(width, height);
1223 (self.main, self.cross) = match self.axis {
1224 CarouselAxis::Horizontal => (width, height),
1225 CarouselAxis::Vertical => (height, width),
1226 };
1227
1228 let content = self.content_size();
1229 for pod in self.pods.iter_mut() {
1230 pod.layout_child(ctx, &BoxConstraints::tight(content));
1231 }
1232
1233 if self.drag.is_none() && !self.settle.is_animating() {
1236 self.index = self.index.min(self.pods.len().saturating_sub(1));
1237 let target = self.offset_for(self.index);
1238 self.offset = target;
1239 }
1240 self.set_offset(self.offset);
1241 bc.constrain(size)
1242 }
1243
1244 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1245 self.last_frame_time = ctx.frame_time();
1248 let (radius, content_color, reduce_motion) = {
1249 let theme = Theme::from_paint_ctx(ctx);
1250 (
1251 resolve_radius(self.item_radius, theme),
1252 resolve_content_color(theme),
1253 theme.is_some_and(|t| t.motion.reduce_motion),
1254 )
1255 };
1256 self.advance_settle(ctx, reduce_motion);
1257
1258 let origin = ctx.origin();
1259 let size = ctx.size();
1260 scene.push_clip(origin, size);
1261 let slots = std::mem::take(&mut self.slots);
1263 for slot in &slots {
1264 let local = self.item_rect(slot);
1265 if local.width() <= 0.0 || local.height() <= 0.0 {
1266 continue;
1267 }
1268 let at = Point::new(origin.x + local.x0, origin.y + local.y0);
1269 let item_size = local.size();
1270 scene.push_clip_rounded(at, item_size, radius);
1276 if let Some(pod) = self.pods.get_mut(slot.index) {
1277 pod.paint_child(ctx, scene);
1278 }
1279 if self.pressed == Some(slot.index) {
1280 self.state.paint(
1281 ctx,
1282 scene,
1283 Rect::from_origin_size(at, item_size),
1284 radius,
1285 content_color,
1286 );
1287 }
1288 scene.pop_clip();
1289 }
1290 self.slots = slots;
1291 scene.pop_clip();
1292 }
1293
1294 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1295 self.event_at(ctx, event, self.event_time_ms())
1296 }
1297
1298 fn semantics(&self, ctx: &mut SemanticsCtx) {
1299 let pods = &self.pods;
1300 ctx.push_container(
1301 Role::Group,
1302 |_| {},
1303 |ctx| {
1304 for pod in pods {
1305 pod.semantics_child(ctx);
1306 }
1307 },
1308 );
1309 }
1310
1311 frust::authoring::visit_children!(pods);
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use frust::authoring::{PointerButton, PointerEvent, PointerPhase, any};
1318 use std::any::Any;
1319
1320 const WINDOW: Size = Size::new(600.0, 200.0);
1322 const STRIDE: f64 = 300.0;
1324
1325 #[derive(Default)]
1326 struct Reports {
1327 changes: Vec<CarouselChange>,
1328 taps: Vec<usize>,
1329 presses: usize,
1330 }
1331
1332 struct Item;
1335
1336 struct ItemWidget;
1338
1339 impl<S: 'static> View<S> for Item {
1340 type Element = ItemWidget;
1341 fn build(&self, _ctx: &mut BuildCtx<'_>) -> ItemWidget {
1342 ItemWidget
1343 }
1344 fn rebuild(
1345 &self,
1346 _prev: &Self,
1347 _element: &mut ItemWidget,
1348 _ctx: &mut BuildCtx<'_>,
1349 ) -> ChangeFlags {
1350 ChangeFlags::NONE
1351 }
1352 }
1353
1354 impl Widget for ItemWidget {
1355 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1356 bc.max()
1357 }
1358 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1359 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1360 if let InputEvent::Pointer(p) = event
1361 && p.phase == PointerPhase::Down
1362 {
1363 ctx.state_mut::<Reports>().presses += 1;
1364 ctx.request_focus();
1367 return EventResult::Handled;
1368 }
1369 EventResult::Ignored
1370 }
1371 }
1372
1373 #[derive(Default)]
1374 struct RecordingScene {
1375 clips: Vec<(Point, Size)>,
1376 rounded: Vec<(Point, Size, f64)>,
1377 rrects: Vec<(Point, Size, f64, Color)>,
1378 }
1379
1380 impl PaintScene for RecordingScene {
1381 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1382 fn draw_text(&mut self, _o: Point, _t: &str) {}
1383 fn push_clip(&mut self, origin: Point, size: Size) {
1384 self.clips.push((origin, size));
1385 }
1386 fn push_clip_rounded(&mut self, origin: Point, size: Size, radius: f64) {
1387 self.rounded.push((origin, size, radius));
1388 }
1389 fn pop_clip(&mut self) {}
1390 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1391 self.rrects.push((o, s, radius, color));
1392 }
1393 }
1394
1395 fn items(count: usize) -> Vec<AnyView<Reports>> {
1396 (0..count).map(|_| any(Item)).collect()
1397 }
1398
1399 fn view(count: usize) -> CarouselView<Reports> {
1400 contained_carousel(items(count))
1401 .on_change(|s: &mut Reports, change: CarouselChange| s.changes.push(change))
1402 .on_tap(|s: &mut Reports, index: usize| s.taps.push(index))
1403 }
1404
1405 fn build(v: &CarouselView<Reports>) -> CarouselWidget {
1406 let mut counter = 0u64;
1407 View::<Reports>::build(v, &mut BuildCtx::new(&mut counter))
1408 }
1409
1410 fn laid_out(v: &CarouselView<Reports>) -> CarouselWidget {
1411 let mut w = build(v);
1412 let mut ctx = LayoutCtx::new();
1413 w.layout(&mut ctx, &BoxConstraints::loose(WINDOW));
1414 w
1415 }
1416
1417 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1418 InputEvent::Pointer(PointerEvent {
1419 phase,
1420 position: Point::new(x, y),
1421 button: PointerButton::Primary,
1422 })
1423 }
1424
1425 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1426 InputEvent::Pointer(PointerEvent {
1427 phase,
1428 position: Point::new(x, y),
1429 button: PointerButton::Secondary,
1430 })
1431 }
1432
1433 fn dispatch(w: &mut CarouselWidget, state: &mut Reports, event: &InputEvent, t_ms: f64) {
1436 let state_any: &mut dyn Any = state;
1437 let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
1438 w.event_at(&mut ctx, event, t_ms);
1439 }
1440
1441 fn paint(w: &mut CarouselWidget) -> RecordingScene {
1442 let mut ctx = PaintCtx::new(Point::ZERO, WINDOW);
1443 let mut scene = RecordingScene::default();
1444 w.paint(&mut ctx, &mut scene);
1445 scene
1446 }
1447
1448 fn finish_settle(w: &mut CarouselWidget) {
1451 w.settle.advance(FrameTime::from_nanos(0));
1452 w.settle.advance(FrameTime::from_nanos(2_000_000_000));
1453 let value = w.settle.value_clamped();
1454 let next = w.settle_from + (w.settle_to - w.settle_from) * value;
1455 w.set_offset(next);
1456 }
1457
1458 #[test]
1461 fn every_child_is_laid_out_at_the_largest_slot_less_its_padding() {
1462 let w = laid_out(&view(6));
1463 for pod in &w.pods {
1464 assert_eq!(
1465 pod.size(),
1466 Size::new(
1467 STRIDE - CAROUSEL_ITEM_PADDING * 2.0,
1468 WINDOW.height - CAROUSEL_ITEM_PADDING * 2.0
1469 ),
1470 "a slot's extent changes with the scroll; the child's does not"
1471 );
1472 }
1473 }
1474
1475 #[test]
1476 fn the_visible_slots_are_the_layouts_own_weights() {
1477 let w = laid_out(&view(6));
1478 assert_eq!(
1479 w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
1480 vec![300.0, 240.0, 60.0]
1481 );
1482 }
1483
1484 #[test]
1485 fn items_beyond_the_viewport_are_parked_outside_the_carousels_bounds() {
1486 let w = laid_out(&view(6));
1487 for pod in &w.pods[3..] {
1488 assert!(
1489 pod.origin().x >= WINDOW.width || pod.origin().x + pod.size().width <= 0.0,
1490 "an invisible item must be unhittable, not merely unpainted"
1491 );
1492 }
1493 }
1494
1495 #[test]
1496 fn a_vertical_carousel_slots_along_the_y_axis() {
1497 let w = laid_out(&view(6).axis(CarouselAxis::Vertical));
1498 assert_eq!(
1500 w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
1501 vec![100.0, 80.0, 20.0]
1502 );
1503 assert_eq!(w.pods[0].origin().y, CAROUSEL_ITEM_PADDING);
1504 assert_eq!(
1505 w.pods[0].size().width,
1506 WINDOW.width - CAROUSEL_ITEM_PADDING * 2.0
1507 );
1508 }
1509
1510 #[test]
1511 fn an_unbounded_axis_collapses_unless_a_size_is_pinned() {
1512 let mut w = build(&view(6));
1513 let mut ctx = LayoutCtx::new();
1514 let size = w.layout(
1515 &mut ctx,
1516 &BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
1517 );
1518 assert_eq!(size, Size::ZERO);
1519
1520 let mut w = build(&view(6).width(400.0).height(120.0));
1521 let mut ctx = LayoutCtx::new();
1522 let size = w.layout(
1523 &mut ctx,
1524 &BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
1525 );
1526 assert_eq!(size, Size::new(400.0, 120.0));
1527 }
1528
1529 #[test]
1530 fn the_hero_layouts_focal_item_is_offset_from_the_leading_one() {
1531 let w = laid_out(&view(6).layout(CarouselLayout::Hero));
1532 assert_eq!(w.focal_slot(), 1, "`[2, 6, 2]`'s max weight is in slot 1");
1533 assert_eq!(w.leading_index(), 0);
1534 assert_eq!(w.focal_index(), 1);
1535
1536 let start = laid_out(
1537 &view(6)
1538 .layout(CarouselLayout::Hero)
1539 .hero_alignment(HeroAlignment::Start),
1540 );
1541 assert_eq!(start.focal_slot(), 0, "`[8, 2]` leads with its max weight");
1542 }
1543
1544 #[test]
1545 fn an_uncontained_carousel_uses_a_fixed_extent_and_the_list_scroll_bound() {
1546 let w = laid_out(&uncontained_carousel(items(6), 200.0));
1547 assert_eq!(
1548 w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
1549 vec![200.0, 200.0, 200.0]
1550 );
1551 assert_eq!(w.max_offset(), 6.0 * 200.0 - WINDOW.width);
1552 assert_eq!(
1553 w.focal_index(),
1554 w.leading_index(),
1555 "no focal slot to shift to"
1556 );
1557 }
1558
1559 #[test]
1560 fn the_initial_item_names_the_focal_item_not_the_leading_one() {
1561 let w = laid_out(&view(8).layout(CarouselLayout::Hero).initial_item(3));
1562 assert_eq!(
1563 w.leading_index(),
1564 2,
1565 "`[2, 6, 2]` shifts the focal item by 1"
1566 );
1567 assert_eq!(w.focal_index(), 3);
1568 assert_eq!(
1569 w.offset(),
1570 2.0 * 120.0,
1571 "and rests on the leading slot item"
1572 );
1573 }
1574
1575 #[test]
1576 fn consume_max_weight_makes_the_slot_item_the_focal_one() {
1577 let w = laid_out(
1581 &view(8)
1582 .layout(CarouselLayout::Hero)
1583 .consume_max_weight(true)
1584 .initial_item(3),
1585 );
1586 assert_eq!(w.focal_index(), 3);
1587 assert_eq!(w.leading_index(), 2);
1588 assert_eq!(w.offset(), 3.0 * 120.0);
1589
1590 let at_rest = laid_out(
1591 &view(8)
1592 .layout(CarouselLayout::Hero)
1593 .consume_max_weight(true),
1594 );
1595 assert_eq!(
1596 at_rest.focal_index(),
1597 0,
1598 "item 0 occupies the largest slot at offset 0"
1599 );
1600 }
1601
1602 #[test]
1605 fn each_visible_item_is_painted_inside_its_own_rounded_clip() {
1606 let mut w = laid_out(&view(6));
1607 let scene = paint(&mut w);
1608 assert_eq!(scene.clips.len(), 1, "the carousel clips its own viewport");
1609 assert_eq!(scene.rounded.len(), 3, "one rounded clip per visible item");
1610 let (origin, size, radius) = scene.rounded[0];
1611 assert_eq!(
1612 origin,
1613 Point::new(CAROUSEL_ITEM_PADDING, CAROUSEL_ITEM_PADDING)
1614 );
1615 assert_eq!(
1616 size,
1617 Size::new(
1618 STRIDE - CAROUSEL_ITEM_PADDING * 2.0,
1619 WINDOW.height - CAROUSEL_ITEM_PADDING * 2.0
1620 )
1621 );
1622 assert_eq!(radius, CAROUSEL_ITEM_RADIUS);
1623 }
1624
1625 #[test]
1626 fn a_theme_resolves_the_item_radius_and_an_explicit_value_wins() {
1627 let theme = crate::baseline();
1628 let mut w = laid_out(&view(6));
1629 let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
1630 let mut scene = RecordingScene::default();
1631 w.paint(&mut ctx, &mut scene);
1632 assert_eq!(scene.rounded[0].2, theme.shape.extra_large);
1633
1634 let mut w = laid_out(&view(6).item_radius(4.0));
1635 let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
1636 let mut scene = RecordingScene::default();
1637 w.paint(&mut ctx, &mut scene);
1638 assert_eq!(scene.rounded[0].2, 4.0);
1639 }
1640
1641 #[test]
1642 fn a_pressed_item_paints_the_state_layer_over_its_own_clip() {
1643 let mut w = laid_out(&view(6));
1644 let mut state = Reports::default();
1645 dispatch(
1646 &mut w,
1647 &mut state,
1648 &ev(PointerPhase::Down, 100.0, 100.0),
1649 0.0,
1650 );
1651 let scene = paint(&mut w);
1652 assert_eq!(scene.rrects.len(), 1, "exactly the pressed item's overlay");
1653 assert_eq!(
1654 scene.rrects[0].0,
1655 Point::new(CAROUSEL_ITEM_PADDING, CAROUSEL_ITEM_PADDING)
1656 );
1657 assert_eq!(
1658 scene.rrects[0].3.components[3],
1659 crate::interaction::PRESSED_OPACITY
1660 );
1661
1662 dispatch(
1663 &mut w,
1664 &mut state,
1665 &ev(PointerPhase::Up, 100.0, 100.0),
1666 16.0,
1667 );
1668 let scene = paint(&mut w);
1669 assert!(scene.rrects.is_empty(), "the overlay clears on release");
1670 }
1671
1672 #[test]
1675 fn a_press_reaches_the_item_under_it_and_a_tap_reports_that_item() {
1676 let mut w = laid_out(&view(6));
1677 let mut state = Reports::default();
1678 dispatch(
1679 &mut w,
1680 &mut state,
1681 &ev(PointerPhase::Down, 400.0, 100.0),
1682 0.0,
1683 );
1684 assert_eq!(
1685 state.presses, 1,
1686 "the item under the pointer sees the press"
1687 );
1688 assert_eq!(w.pressed, Some(1));
1689 dispatch(
1690 &mut w,
1691 &mut state,
1692 &ev(PointerPhase::Up, 400.0, 100.0),
1693 16.0,
1694 );
1695 assert_eq!(state.taps, vec![1]);
1696 assert!(w.pressed.is_none());
1697 }
1698
1699 #[test]
1700 fn a_release_outside_the_pressed_item_reports_no_tap() {
1701 let mut w = laid_out(&view(6));
1702 let mut state = Reports::default();
1703 dispatch(
1704 &mut w,
1705 &mut state,
1706 &ev(PointerPhase::Down, 100.0, 100.0),
1707 0.0,
1708 );
1709 dispatch(
1710 &mut w,
1711 &mut state,
1712 &ev(PointerPhase::Up, 400.0, 100.0),
1713 16.0,
1714 );
1715 assert!(state.taps.is_empty());
1716 }
1717
1718 #[test]
1719 fn a_secondary_press_reaches_the_item_but_arms_no_drag() {
1720 let mut w = laid_out(&view(6));
1721 let mut state = Reports::default();
1722 dispatch(
1723 &mut w,
1724 &mut state,
1725 &secondary_ev(PointerPhase::Down, 100.0, 100.0),
1726 0.0,
1727 );
1728 assert!(w.drag.is_none());
1729 assert!(w.pressed.is_none());
1730 }
1731
1732 #[test]
1733 fn a_drag_past_the_slop_takes_the_gesture_over_and_moves_the_content() {
1734 let mut w = laid_out(&view(6));
1735 let mut state = Reports::default();
1736 dispatch(
1737 &mut w,
1738 &mut state,
1739 &ev(PointerPhase::Down, 400.0, 100.0),
1740 0.0,
1741 );
1742 dispatch(
1744 &mut w,
1745 &mut state,
1746 &ev(PointerPhase::Move, 398.0, 100.0),
1747 8.0,
1748 );
1749 assert_eq!(w.offset, 0.0);
1750 assert_eq!(w.pressed, Some(1), "the press survives a sub-slop wobble");
1751 let past = 400.0 - TOUCH_SLOP - 5.0;
1753 dispatch(
1754 &mut w,
1755 &mut state,
1756 &ev(PointerPhase::Move, past, 100.0),
1757 16.0,
1758 );
1759 assert!(w.pressed.is_none());
1760 dispatch(
1761 &mut w,
1762 &mut state,
1763 &ev(PointerPhase::Move, 250.0, 100.0),
1764 24.0,
1765 );
1766 assert_eq!(w.offset, 150.0, "the content follows the finger");
1767 }
1768
1769 #[test]
1770 fn a_slow_release_mid_item_settles_onto_the_nearest_boundary() {
1771 let mut w = laid_out(&view(6));
1772 let mut state = Reports::default();
1773 dispatch(
1776 &mut w,
1777 &mut state,
1778 &ev(PointerPhase::Down, 500.0, 100.0),
1779 0.0,
1780 );
1781 dispatch(
1782 &mut w,
1783 &mut state,
1784 &ev(PointerPhase::Move, 400.0, 100.0),
1785 400.0,
1786 );
1787 dispatch(
1788 &mut w,
1789 &mut state,
1790 &ev(PointerPhase::Move, 340.0, 100.0),
1791 900.0,
1792 );
1793 assert_eq!(w.offset, 160.0);
1794 dispatch(
1795 &mut w,
1796 &mut state,
1797 &ev(PointerPhase::Up, 340.0, 100.0),
1798 1000.0,
1799 );
1800 assert_eq!(w.settle_to, STRIDE, "settles forward to the item boundary");
1801 finish_settle(&mut w);
1802 assert_eq!(w.offset, STRIDE);
1803 assert_eq!(w.leading_index(), 1);
1804 }
1805
1806 #[test]
1807 fn a_slow_release_before_the_halfway_mark_settles_back() {
1808 let mut w = laid_out(&view(6));
1809 let mut state = Reports::default();
1810 dispatch(
1811 &mut w,
1812 &mut state,
1813 &ev(PointerPhase::Down, 500.0, 100.0),
1814 0.0,
1815 );
1816 dispatch(
1817 &mut w,
1818 &mut state,
1819 &ev(PointerPhase::Move, 440.0, 100.0),
1820 500.0,
1821 );
1822 dispatch(
1823 &mut w,
1824 &mut state,
1825 &ev(PointerPhase::Move, 420.0, 100.0),
1826 1000.0,
1827 );
1828 assert_eq!(w.offset, 80.0);
1829 dispatch(
1830 &mut w,
1831 &mut state,
1832 &ev(PointerPhase::Up, 420.0, 100.0),
1833 1100.0,
1834 );
1835 assert_eq!(w.settle_to, 0.0);
1836 finish_settle(&mut w);
1837 assert_eq!(w.offset, 0.0);
1838 assert_eq!(w.leading_index(), 0);
1839 }
1840
1841 #[test]
1842 fn a_short_fast_fling_still_advances_one_whole_item() {
1843 let mut w = laid_out(&view(6));
1844 let mut state = Reports::default();
1845 dispatch(
1846 &mut w,
1847 &mut state,
1848 &ev(PointerPhase::Down, 500.0, 100.0),
1849 0.0,
1850 );
1851 dispatch(
1855 &mut w,
1856 &mut state,
1857 &ev(PointerPhase::Move, 470.0, 100.0),
1858 8.0,
1859 );
1860 dispatch(
1861 &mut w,
1862 &mut state,
1863 &ev(PointerPhase::Move, 440.0, 100.0),
1864 16.0,
1865 );
1866 assert_eq!(w.offset, 60.0);
1867 dispatch(
1868 &mut w,
1869 &mut state,
1870 &ev(PointerPhase::Up, 440.0, 100.0),
1871 16.0,
1872 );
1873 assert_eq!(w.settle_to, STRIDE, "a fling advances one item");
1874 finish_settle(&mut w);
1875 assert_eq!(w.leading_index(), 1);
1876 }
1877
1878 #[test]
1879 fn a_backward_fling_returns_to_the_previous_item() {
1880 let mut w = laid_out(&view(6));
1881 w.set_offset(STRIDE);
1882 w.index = 1;
1883 let mut state = Reports::default();
1884 dispatch(
1885 &mut w,
1886 &mut state,
1887 &ev(PointerPhase::Down, 300.0, 100.0),
1888 0.0,
1889 );
1890 dispatch(
1891 &mut w,
1892 &mut state,
1893 &ev(PointerPhase::Move, 330.0, 100.0),
1894 8.0,
1895 );
1896 dispatch(
1897 &mut w,
1898 &mut state,
1899 &ev(PointerPhase::Move, 360.0, 100.0),
1900 16.0,
1901 );
1902 assert_eq!(
1903 w.offset, 240.0,
1904 "only 60px back — a slow release would stay"
1905 );
1906 dispatch(
1907 &mut w,
1908 &mut state,
1909 &ev(PointerPhase::Up, 360.0, 100.0),
1910 16.0,
1911 );
1912 assert_eq!(w.settle_to, 0.0);
1913 finish_settle(&mut w);
1914 assert_eq!(w.leading_index(), 0);
1915 }
1916
1917 #[test]
1918 fn a_scroll_never_leaves_the_scrollable_range() {
1919 let mut w = laid_out(&view(6));
1920 let mut state = Reports::default();
1921 dispatch(
1922 &mut w,
1923 &mut state,
1924 &ev(PointerPhase::Down, 500.0, 100.0),
1925 0.0,
1926 );
1927 dispatch(
1928 &mut w,
1929 &mut state,
1930 &ev(PointerPhase::Move, 100.0, 100.0),
1931 8.0,
1932 );
1933 dispatch(
1934 &mut w,
1935 &mut state,
1936 &ev(PointerPhase::Move, -5000.0, 100.0),
1937 16.0,
1938 );
1939 assert_eq!(w.offset, w.max_offset());
1940 dispatch(
1941 &mut w,
1942 &mut state,
1943 &ev(PointerPhase::Move, 9000.0, 100.0),
1944 24.0,
1945 );
1946 assert_eq!(w.offset, 0.0);
1947 }
1948
1949 #[test]
1950 fn a_carousel_with_nothing_to_scroll_never_takes_a_gesture_over() {
1951 let mut w = laid_out(&view(3));
1953 assert!(!w.scrollable());
1954 let mut state = Reports::default();
1955 dispatch(
1956 &mut w,
1957 &mut state,
1958 &ev(PointerPhase::Down, 100.0, 100.0),
1959 0.0,
1960 );
1961 dispatch(
1962 &mut w,
1963 &mut state,
1964 &ev(PointerPhase::Move, -500.0, 100.0),
1965 16.0,
1966 );
1967 assert_eq!(w.offset, 0.0);
1968 assert_eq!(w.pressed, Some(0), "the press stays with the item");
1969 }
1970
1971 #[test]
1972 fn a_wheel_tick_moves_the_content_and_settles_onto_a_boundary() {
1973 let mut w = laid_out(&view(6));
1974 let mut state = Reports::default();
1975 let wheel = InputEvent::Scroll {
1976 position: Point::new(100.0, 100.0),
1977 delta: frust::authoring::ScrollDelta::Pixels(180.0, 0.0),
1978 };
1979 dispatch(&mut w, &mut state, &wheel, 0.0);
1980 assert_eq!(w.offset, 180.0, "the content follows the wheel");
1981 assert_eq!(w.settle_to, STRIDE, "and settles onto the nearest boundary");
1982 finish_settle(&mut w);
1983 assert_eq!(w.leading_index(), 1);
1984 }
1985
1986 #[test]
1987 fn a_press_on_another_item_clears_the_previous_ones_focus_link() {
1988 let mut w = laid_out(&view(6));
1989 let mut state = Reports::default();
1990 dispatch(
1991 &mut w,
1992 &mut state,
1993 &ev(PointerPhase::Down, 100.0, 100.0),
1994 0.0,
1995 );
1996 dispatch(
1997 &mut w,
1998 &mut state,
1999 &ev(PointerPhase::Up, 100.0, 100.0),
2000 16.0,
2001 );
2002 assert!(
2003 w.pods[0].is_focused(),
2004 "the item claimed focus on its press"
2005 );
2006 dispatch(
2007 &mut w,
2008 &mut state,
2009 &ev(PointerPhase::Down, 400.0, 100.0),
2010 32.0,
2011 );
2012 assert!(!w.pods[0].is_focused(), "a press elsewhere blurs it");
2013 assert!(w.pods[1].is_focused());
2014 }
2015
2016 #[test]
2017 fn a_cancel_clears_the_gesture_and_settles_back_without_reporting() {
2018 let mut w = laid_out(&view(6));
2019 let mut state = Reports::default();
2020 dispatch(
2021 &mut w,
2022 &mut state,
2023 &ev(PointerPhase::Down, 500.0, 100.0),
2024 0.0,
2025 );
2026 dispatch(
2027 &mut w,
2028 &mut state,
2029 &ev(PointerPhase::Move, 350.0, 100.0),
2030 16.0,
2031 );
2032 let reported = state.changes.len();
2033 dispatch(
2034 &mut w,
2035 &mut state,
2036 &ev(PointerPhase::Cancel, 350.0, 100.0),
2037 24.0,
2038 );
2039 assert!(w.drag.is_none());
2040 assert!(w.pressed.is_none());
2041 assert_eq!(w.settle_to, 0.0, "back onto the item it started from");
2042 assert_eq!(state.changes.len(), reported, "a cancel reports nothing");
2043 assert!(state.taps.is_empty());
2044 }
2045
2046 #[test]
2049 fn a_scroll_reports_the_leading_and_focal_item_at_the_halfway_mark() {
2050 let mut w = laid_out(&view(8).layout(CarouselLayout::Hero));
2051 let mut state = Reports::default();
2052 dispatch(
2054 &mut w,
2055 &mut state,
2056 &ev(PointerPhase::Down, 500.0, 100.0),
2057 0.0,
2058 );
2059 dispatch(
2061 &mut w,
2062 &mut state,
2063 &ev(PointerPhase::Move, 470.0, 100.0),
2064 100.0,
2065 );
2066 dispatch(
2067 &mut w,
2068 &mut state,
2069 &ev(PointerPhase::Move, 450.0, 100.0),
2070 200.0,
2071 );
2072 assert!(state.changes.is_empty(), "50px in: still nearest to item 0");
2073 dispatch(
2074 &mut w,
2075 &mut state,
2076 &ev(PointerPhase::Move, 420.0, 100.0),
2077 400.0,
2078 );
2079 assert_eq!(
2080 state.changes.last().copied(),
2081 Some(CarouselChange {
2082 leading_index: 1,
2083 focal_index: 2,
2084 item_count: 8
2085 })
2086 );
2087 }
2088
2089 #[test]
2090 fn a_controlled_carousel_reports_the_request_and_moves_nothing() {
2091 let mut w = laid_out(&view(6).selected(1));
2092 assert!(w.controlled);
2093 let mut state = Reports::default();
2094 dispatch(
2095 &mut w,
2096 &mut state,
2097 &ev(PointerPhase::Down, 500.0, 100.0),
2098 0.0,
2099 );
2100 dispatch(
2101 &mut w,
2102 &mut state,
2103 &ev(PointerPhase::Move, 400.0, 100.0),
2104 400.0,
2105 );
2106 dispatch(
2107 &mut w,
2108 &mut state,
2109 &ev(PointerPhase::Move, 340.0, 100.0),
2110 900.0,
2111 );
2112 dispatch(
2113 &mut w,
2114 &mut state,
2115 &ev(PointerPhase::Up, 340.0, 100.0),
2116 1000.0,
2117 );
2118 assert!(!state.changes.is_empty(), "the request is still reported");
2119 assert_eq!(
2120 w.settle_to,
2121 w.offset_for(w.index),
2122 "it settles back onto the app-confirmed item"
2123 );
2124 }
2125
2126 #[test]
2127 fn a_rebuild_with_a_new_selection_settles_onto_it() {
2128 let mut counter = 0u64;
2129 let mut ctx = BuildCtx::new(&mut counter);
2130 let first = view(8).selected(0);
2131 let mut w = View::<Reports>::build(&first, &mut ctx);
2132 let mut layout_ctx = LayoutCtx::new();
2133 w.layout(&mut layout_ctx, &BoxConstraints::loose(WINDOW));
2134 let next = view(8).selected(3);
2135 View::<Reports>::rebuild(&next, &first, &mut w, &mut ctx);
2136 assert_eq!(w.leading_index(), 3);
2137 finish_settle(&mut w);
2138 assert_eq!(w.offset, STRIDE * 3.0);
2139 }
2140
2141 #[test]
2142 fn reduce_motion_lands_on_the_target_without_asking_for_a_frame() {
2143 let mut theme = crate::baseline();
2144 theme.motion.reduce_motion = true;
2145 let mut w = laid_out(&view(6));
2146 let mut state = Reports::default();
2147 dispatch(
2148 &mut w,
2149 &mut state,
2150 &ev(PointerPhase::Down, 500.0, 100.0),
2151 0.0,
2152 );
2153 dispatch(
2154 &mut w,
2155 &mut state,
2156 &ev(PointerPhase::Move, 400.0, 100.0),
2157 400.0,
2158 );
2159 dispatch(
2160 &mut w,
2161 &mut state,
2162 &ev(PointerPhase::Move, 340.0, 100.0),
2163 900.0,
2164 );
2165 dispatch(
2166 &mut w,
2167 &mut state,
2168 &ev(PointerPhase::Up, 340.0, 100.0),
2169 1000.0,
2170 );
2171 let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
2172 let mut scene = RecordingScene::default();
2173 w.paint(&mut ctx, &mut scene);
2174 assert_eq!(w.offset, STRIDE, "jumped straight to the target");
2175 assert!(!ctx.needs_frame(), "and asked for no continuation frame");
2176 }
2177
2178 #[test]
2179 fn a_settle_asks_for_the_next_frame_while_it_runs() {
2180 let mut w = laid_out(&view(6));
2181 let mut state = Reports::default();
2182 dispatch(
2183 &mut w,
2184 &mut state,
2185 &ev(PointerPhase::Down, 500.0, 100.0),
2186 0.0,
2187 );
2188 dispatch(
2189 &mut w,
2190 &mut state,
2191 &ev(PointerPhase::Move, 400.0, 100.0),
2192 400.0,
2193 );
2194 dispatch(
2195 &mut w,
2196 &mut state,
2197 &ev(PointerPhase::Move, 340.0, 100.0),
2198 900.0,
2199 );
2200 dispatch(
2201 &mut w,
2202 &mut state,
2203 &ev(PointerPhase::Up, 340.0, 100.0),
2204 1000.0,
2205 );
2206 let mut ctx = PaintCtx::new(Point::ZERO, WINDOW);
2207 let mut scene = RecordingScene::default();
2208 w.paint(&mut ctx, &mut scene);
2209 assert!(ctx.needs_frame());
2210 }
2211
2212 #[test]
2213 fn an_empty_carousel_lays_out_and_paints_without_panicking() {
2214 let mut w = laid_out(&view(0));
2215 assert!(w.slots.is_empty());
2216 let scene = paint(&mut w);
2217 assert!(scene.rounded.is_empty());
2218 let mut state = Reports::default();
2219 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0), 0.0);
2220 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0), 16.0);
2221 assert!(state.taps.is_empty());
2222 }
2223
2224 #[test]
2225 fn a_zero_sized_carousel_degrades_instead_of_dividing_by_its_viewport() {
2226 let mut w = build(&view(6));
2227 let mut ctx = LayoutCtx::new();
2228 let size = w.layout(&mut ctx, &BoxConstraints::tight(Size::ZERO));
2229 assert_eq!(size, Size::ZERO);
2230 assert!(w.slots.is_empty());
2231 assert_eq!(w.max_offset(), 0.0);
2232 let scene = paint(&mut w);
2233 assert!(scene.rounded.is_empty());
2234 }
2235}