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