pub(crate) mod layout;
pub(crate) mod physics;
use std::rc::Rc;
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
PointerPhase, Role, ScrollDelta, SemanticsCtx, TypedArgCallback, View, Widget,
};
use frust::input::{TOUCH_SLOP, VelocityTracker, WHEEL_LINE_PX};
use frust::{AnimationController, FrameTime, Theme};
use kurbo::{Point, Rect, Size};
use peniko::Color;
use self::layout::{
CONTAINED_EXTENDED_WEIGHTS, CONTAINED_WEIGHTS, FixedLayout, HERO_CENTER_WEIGHTS,
HERO_END_WEIGHTS, HERO_START_WEIGHTS, Slot, WeightedLayout,
};
use crate::press::presses;
use crate::state_layer::StateLayer;
pub const CAROUSEL_ITEM_PADDING: f64 = 4.0;
pub const CAROUSEL_ITEM_RADIUS: f64 = 28.0;
pub const UNCONTAINED_ITEM_EXTENT: f64 = 270.0;
pub const UNCONTAINED_SHRINK_EXTENT: f64 = 150.0;
const OFFSET_EPSILON: f64 = 1e-3;
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CarouselLayout {
#[default]
Hero,
Contained,
Uncontained,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HeroAlignment {
Start,
#[default]
Center,
End,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CarouselAxis {
#[default]
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CarouselChange {
pub leading_index: usize,
pub focal_index: usize,
pub item_count: usize,
}
impl CarouselChange {
pub fn is_focal(&self, index: usize) -> bool {
index == self.focal_index
}
}
pub struct CarouselView<State: 'static> {
items: Vec<AnyView<State>>,
layout: CarouselLayout,
hero_alignment: HeroAlignment,
extended: bool,
axis: CarouselAxis,
item_extent: f64,
shrink_extent: Option<f64>,
item_radius: Option<f64>,
width: Option<f64>,
height: Option<f64>,
consume_max_weight: bool,
initial_item: usize,
selected: Option<usize>,
on_change: Option<TypedArgCallback<State, CarouselChange>>,
on_tap: Option<TypedArgCallback<State, usize>>,
}
pub fn carousel<State: 'static>(items: Vec<AnyView<State>>) -> CarouselView<State> {
CarouselView {
items,
layout: CarouselLayout::default(),
hero_alignment: HeroAlignment::default(),
extended: false,
axis: CarouselAxis::default(),
item_extent: UNCONTAINED_ITEM_EXTENT,
shrink_extent: None,
item_radius: None,
width: None,
height: None,
consume_max_weight: false,
initial_item: 0,
selected: None,
on_change: None,
on_tap: None,
}
}
pub fn hero_carousel<State: 'static>(
items: Vec<AnyView<State>>,
alignment: HeroAlignment,
) -> CarouselView<State> {
carousel(items).hero_alignment(alignment)
}
pub fn contained_carousel<State: 'static>(items: Vec<AnyView<State>>) -> CarouselView<State> {
carousel(items).layout(CarouselLayout::Contained)
}
pub fn uncontained_carousel<State: 'static>(
items: Vec<AnyView<State>>,
item_extent: f64,
) -> CarouselView<State> {
carousel(items)
.layout(CarouselLayout::Uncontained)
.item_extent(item_extent)
}
impl<State: 'static> CarouselView<State> {
pub fn layout(mut self, layout: CarouselLayout) -> Self {
self.layout = layout;
self
}
pub fn hero_alignment(mut self, alignment: HeroAlignment) -> Self {
self.hero_alignment = alignment;
self
}
pub fn extended(mut self, extended: bool) -> Self {
self.extended = extended;
self
}
pub fn axis(mut self, axis: CarouselAxis) -> Self {
self.axis = axis;
self
}
pub fn item_extent(mut self, extent: f64) -> Self {
self.item_extent = extent;
self
}
pub fn shrink_extent(mut self, extent: f64) -> Self {
self.shrink_extent = Some(extent);
self
}
pub fn item_radius(mut self, radius: f64) -> Self {
self.item_radius = Some(radius);
self
}
pub fn width(mut self, width: f64) -> Self {
self.width = Some(width);
self
}
pub fn height(mut self, height: f64) -> Self {
self.height = Some(height);
self
}
pub fn consume_max_weight(mut self, consume: bool) -> Self {
self.consume_max_weight = consume;
self
}
pub fn initial_item(mut self, index: usize) -> Self {
self.initial_item = index;
self
}
pub fn selected(mut self, focal_index: usize) -> Self {
self.selected = Some(focal_index);
self
}
pub fn on_change<F: Fn(&mut State, CarouselChange) + 'static>(mut self, on_change: F) -> Self {
self.on_change = Some(Rc::new(on_change));
self
}
pub fn on_tap<F: Fn(&mut State, usize) + 'static>(mut self, on_tap: F) -> Self {
self.on_tap = Some(Rc::new(on_tap));
self
}
fn sizing(&self) -> Sizing {
match self.layout {
CarouselLayout::Hero => Sizing::Weighted(match self.hero_alignment {
HeroAlignment::Start => HERO_START_WEIGHTS,
HeroAlignment::Center => HERO_CENTER_WEIGHTS,
HeroAlignment::End => HERO_END_WEIGHTS,
}),
CarouselLayout::Contained => Sizing::Weighted(if self.extended {
CONTAINED_EXTENDED_WEIGHTS
} else {
CONTAINED_WEIGHTS
}),
CarouselLayout::Uncontained => Sizing::Fixed {
item_extent: self.item_extent,
},
}
}
fn resolved_shrink_extent(&self) -> f64 {
self.shrink_extent.unwrap_or(match self.layout {
CarouselLayout::Uncontained => UNCONTAINED_SHRINK_EXTENT,
_ => 0.0,
})
}
}
impl<State: 'static> View<State> for CarouselView<State> {
type Element = CarouselWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> CarouselWidget {
let sizing = self.sizing();
let mut widget = CarouselWidget {
pods: self
.items
.iter()
.map(|item| frust::authoring::build_child(item, ctx))
.collect(),
sizing,
axis: self.axis,
shrink_extent: self.resolved_shrink_extent(),
item_radius: self.item_radius,
width: self.width,
height: self.height,
consume_max_weight: self.consume_max_weight,
controlled: self.selected.is_some(),
index: 0,
offset: 0.0,
settle: physics::settle_controller(),
settle_from: 0.0,
settle_to: 0.0,
main: 0.0,
cross: 0.0,
slots: Vec::new(),
drag: None,
pressed: None,
state: StateLayer::new(),
tracker: VelocityTracker::new(),
last_frame_time: FrameTime::ZERO,
reported: (0, 0),
on_change: self
.on_change
.as_ref()
.map(frust::authoring::erase_callback_arg),
on_tap: self
.on_tap
.as_ref()
.map(frust::authoring::erase_callback_arg),
};
widget.index = widget.leading_for_focal(self.selected.unwrap_or(self.initial_item));
widget.reported = widget.indices_for_leading(widget.index);
widget
}
fn rebuild(
&self,
prev: &Self,
element: &mut CarouselWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = frust::authoring::rebuild_children(
&prev.items,
&self.items,
&mut element.pods,
ctx,
|view: &AnyView<State>| view,
|_| None,
);
let sizing = self.sizing();
let shrink_extent = self.resolved_shrink_extent();
if element.sizing != sizing
|| element.axis != self.axis
|| element.shrink_extent != shrink_extent
|| element.width != self.width
|| element.height != self.height
|| element.consume_max_weight != self.consume_max_weight
{
element.sizing = sizing;
element.axis = self.axis;
element.shrink_extent = shrink_extent;
element.width = self.width;
element.height = self.height;
element.consume_max_weight = self.consume_max_weight;
element.settle.stop();
element.offset = element.offset_for(element.index);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.item_radius != self.item_radius {
element.item_radius = self.item_radius;
flags |= ChangeFlags::PAINT;
}
element.controlled = self.selected.is_some();
let last = element.pods.len().saturating_sub(1);
if let Some(selected) = self.selected {
let index = element.leading_for_focal(selected);
if element.index != index {
element.index = index;
element.reported = element.indices_for_leading(index);
element.start_settle();
flags |= ChangeFlags::PAINT;
}
} else if element.index > last {
element.index = last;
element.reported = element.indices_for_leading(last);
element.start_settle();
flags |= ChangeFlags::PAINT;
}
element.on_change = self
.on_change
.as_ref()
.map(frust::authoring::erase_callback_arg);
element.on_tap = self
.on_tap
.as_ref()
.map(frust::authoring::erase_callback_arg);
flags
}
fn teardown(&self, element: &mut CarouselWidget, ctx: &mut BuildCtx<'_>) {
for (view, pod) in self.items.iter().zip(element.pods.iter_mut()) {
frust::authoring::teardown_child(view, pod, ctx);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Sizing {
Weighted(&'static [u16]),
Fixed { item_extent: f64 },
}
enum Solver {
Weighted(WeightedLayout<'static>),
Fixed(FixedLayout),
}
impl Solver {
fn slots(&self, item_count: usize) -> Vec<Slot> {
match self {
Solver::Weighted(l) => l.slots(item_count),
Solver::Fixed(l) => l.slots(item_count),
}
}
fn stride(&self) -> f64 {
match self {
Solver::Weighted(l) => l.first_child_extent(),
Solver::Fixed(l) => l.stride(),
}
}
fn slot_extent(&self) -> f64 {
match self {
Solver::Weighted(l) => l.max_child_extent(),
Solver::Fixed(l) => l.stride(),
}
}
fn max_scroll_offset(&self, item_count: usize) -> f64 {
match self {
Solver::Weighted(l) => l.max_scroll_offset(item_count),
Solver::Fixed(l) => l.max_scroll_offset(item_count),
}
}
}
struct Drag {
start: f64,
from: f64,
item: Option<usize>,
scrolling: bool,
}
pub struct CarouselWidget {
pods: Vec<ChildPod>,
sizing: Sizing,
axis: CarouselAxis,
shrink_extent: f64,
item_radius: Option<f64>,
width: Option<f64>,
height: Option<f64>,
consume_max_weight: bool,
controlled: bool,
index: usize,
offset: f64,
settle: AnimationController,
settle_from: f64,
settle_to: f64,
main: f64,
cross: f64,
slots: Vec<Slot>,
drag: Option<Drag>,
pressed: Option<usize>,
state: StateLayer,
tracker: VelocityTracker,
last_frame_time: FrameTime,
reported: (usize, usize),
on_change: Option<ErasedArgCallback<CarouselChange>>,
on_tap: Option<ErasedArgCallback<usize>>,
}
impl CarouselWidget {
pub fn leading_index(&self) -> usize {
self.indices_for_leading(self.index).0
}
pub fn focal_index(&self) -> usize {
self.indices_for_leading(self.index).1
}
pub fn offset(&self) -> f64 {
self.offset
}
fn solver(&self, offset: f64) -> Solver {
match self.sizing {
Sizing::Weighted(weights) => Solver::Weighted(WeightedLayout::new(
weights,
self.main,
self.shrink_extent,
offset,
self.consume_max_weight,
)),
Sizing::Fixed { item_extent } => Solver::Fixed(FixedLayout::new(
item_extent,
self.shrink_extent,
self.main,
offset,
)),
}
}
fn focal_slot(&self) -> usize {
match self.sizing {
Sizing::Weighted(weights) => {
let max = weights.iter().copied().max().unwrap_or(0);
weights.iter().position(|w| *w == max).unwrap_or(0)
}
Sizing::Fixed { .. } => 0,
}
}
fn leading_for_focal(&self, focal: usize) -> usize {
let last = self.pods.len().saturating_sub(1);
let raw = if self.consume_max_weight {
focal
} else {
focal.saturating_sub(self.focal_slot())
};
raw.min(last)
}
fn indices_for_leading(&self, raw: usize) -> (usize, usize) {
let last = self.pods.len().saturating_sub(1);
let raw = raw.min(last);
if self.consume_max_weight {
(raw.saturating_sub(self.focal_slot()), raw)
} else {
(raw, (raw + self.focal_slot()).min(last))
}
}
fn indices_at(&self, offset: f64) -> (usize, usize) {
let raw = physics::nearest_item(offset, self.solver(offset).stride());
self.indices_for_leading(raw)
}
fn offset_for(&self, index: usize) -> f64 {
let solver = self.solver(self.offset);
let target = index as f64 * solver.stride();
target
.max(0.0)
.min(solver.max_scroll_offset(self.pods.len()).max(0.0))
}
fn max_offset(&self) -> f64 {
self.solver(self.offset)
.max_scroll_offset(self.pods.len())
.max(0.0)
}
fn scrollable(&self) -> bool {
self.max_offset() > 0.0
}
fn set_offset(&mut self, offset: f64) {
let offset = if offset.is_finite() { offset } else { 0.0 };
self.offset = offset.max(0.0).min(self.max_offset());
self.sync_geometry();
}
fn sync_geometry(&mut self) {
self.slots = self.solver(self.offset).slots(self.pods.len());
let content = self.content_size();
let first_visible = self.slots.first().map(|s| s.index);
for (index, pod) in self.pods.iter_mut().enumerate() {
let origin = match self.slots.iter().find(|s| s.index == index) {
Some(slot) => {
let along =
slot.offset + (slot.extent - content_main(self.axis, content)) / 2.0;
let across = (self.cross - content_cross(self.axis, content)) / 2.0;
axis_point(self.axis, along, across)
}
None => {
let along = if first_visible.is_some_and(|first| index < first) {
-content_main(self.axis, content) - 1.0
} else {
self.main + 1.0
};
axis_point(self.axis, along, 0.0)
}
};
pod.set_origin(origin);
}
}
fn content_size(&self) -> Size {
let slot = self.solver(self.offset).slot_extent();
let main = (slot - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
let cross = (self.cross - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
match self.axis {
CarouselAxis::Horizontal => Size::new(main, cross),
CarouselAxis::Vertical => Size::new(cross, main),
}
}
fn item_rect(&self, slot: &Slot) -> Rect {
let main = (slot.extent - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
let cross = (self.cross - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
let origin = axis_point(
self.axis,
slot.offset + CAROUSEL_ITEM_PADDING,
CAROUSEL_ITEM_PADDING,
);
let size = match self.axis {
CarouselAxis::Horizontal => Size::new(main, cross),
CarouselAxis::Vertical => Size::new(cross, main),
};
Rect::from_origin_size(origin, size)
}
fn item_at(&self, pos: Point) -> Option<usize> {
self.slots
.iter()
.find(|slot| self.item_rect(slot).contains(pos))
.map(|slot| slot.index)
}
fn main_of(&self, point: Point) -> f64 {
match self.axis {
CarouselAxis::Horizontal => point.x,
CarouselAxis::Vertical => point.y,
}
}
fn event_time_ms(&self) -> f64 {
self.last_frame_time.as_secs_f64() * 1000.0
}
fn settle_to(&mut self, target: f64) {
self.settle_from = self.offset;
self.settle_to = target;
self.settle = physics::settle_controller();
self.settle.forward();
}
fn start_settle(&mut self) {
let target = self.offset_for(self.index);
self.settle_to(target);
}
fn report(&mut self, ctx: &mut EventCtx, offset: f64) {
let pair = self.indices_at(offset);
if pair == self.reported {
return;
}
self.reported = pair;
let count = self.pods.len();
if let Some(on_change) = self.on_change.as_mut() {
on_change(
ctx,
CarouselChange {
leading_index: pair.0,
focal_index: pair.1,
item_count: count,
},
);
}
}
fn cancel_item(&mut self, ctx: &mut EventCtx, item: usize, pos: Point) {
if let Some(pod) = self.pods.get_mut(item) {
let cancel = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Cancel,
position: pos,
button: PointerButton::Primary,
});
pod.event_child(ctx, &cancel);
pod.set_active(false);
}
}
fn blur_unhit(&mut self, hit: Option<usize>) {
let kept = hit.filter(|index| self.pods.get(*index).is_some_and(ChildPod::is_focused));
for (index, pod) in self.pods.iter_mut().enumerate() {
if Some(index) != kept && pod.is_focused() {
pod.set_focused(false);
}
}
}
fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
if event.is_broadcast() {
return frust::authoring::route_event(&mut self.pods, ctx, event);
}
if let InputEvent::Scroll { delta, .. } = event
&& self.drag.is_none()
&& self.scrollable()
{
return self.wheel(ctx, delta);
}
let InputEvent::Pointer(p) = event else {
return frust::authoring::route_event(&mut self.pods, ctx, event);
};
if self.drag.is_some() {
return self.drag_event(ctx, event, p, t_ms);
}
match p.phase {
PointerPhase::Down => {
let item = self.item_at(p.position);
if !presses(p) {
let result = self.forward_to_item(ctx, event, item);
self.blur_unhit(item);
return result;
}
let main = self.main_of(p.position);
self.drag = Some(Drag {
start: main,
from: self.offset,
item,
scrolling: false,
});
self.tracker.clear();
self.tracker.record(t_ms, main);
self.settle.stop();
self.pressed = item;
self.state.set_pressed(item.is_some());
ctx.capture_pointer();
self.forward_to_item(ctx, event, item);
self.blur_unhit(item);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
self.forward_to_item(ctx, event, self.item_at(p.position));
EventResult::Ignored
}
PointerPhase::Up | PointerPhase::Cancel => EventResult::Ignored,
}
}
fn drag_event(
&mut self,
ctx: &mut EventCtx,
event: &InputEvent,
p: &PointerEvent,
t_ms: f64,
) -> EventResult {
let main = self.main_of(p.position);
match p.phase {
PointerPhase::Down => EventResult::Handled,
PointerPhase::Move => {
self.tracker.record(t_ms, main);
let Some(drag) = self.drag.as_ref() else {
return EventResult::Handled;
};
let (from, start, item, scrolling) =
(drag.from, drag.start, drag.item, drag.scrolling);
if scrolling {
self.set_offset(from - (main - start));
self.report(ctx, self.offset);
ctx.request_redraw();
return EventResult::Handled;
}
if self.scrollable() && (main - start).abs() > TOUCH_SLOP {
if let Some(item) = item {
self.cancel_item(ctx, item, p.position);
}
self.pressed = None;
self.state.set_pressed(false);
if let Some(drag) = self.drag.as_mut() {
drag.scrolling = true;
}
ctx.request_redraw();
return EventResult::Handled;
}
self.forward_to_item(ctx, event, item);
EventResult::Handled
}
PointerPhase::Up => {
let Some(drag) = self.drag.take() else {
return EventResult::Handled;
};
self.pressed = None;
self.state.set_pressed(false);
if drag.scrolling {
self.release(ctx);
} else {
self.forward_to_item(ctx, event, drag.item);
if let Some(item) = drag.item
&& self.item_at(p.position) == Some(item)
&& let Some(on_tap) = self.on_tap.as_mut()
{
on_tap(ctx, item);
}
}
if let Some(item) = drag.item
&& let Some(pod) = self.pods.get_mut(item)
{
pod.set_active(false);
}
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
let drag = self.drag.take();
self.pressed = None;
self.state.set_pressed(false);
if let Some(drag) = &drag
&& let Some(item) = drag.item
{
if !drag.scrolling {
self.forward_to_item(ctx, event, Some(item));
}
if let Some(pod) = self.pods.get_mut(item) {
pod.set_active(false);
}
}
self.start_settle();
ctx.request_redraw();
EventResult::Handled
}
}
}
fn release(&mut self, ctx: &mut EventCtx) {
let scroll_velocity = -self.tracker.velocity();
self.settle_snapped(ctx, scroll_velocity);
}
fn settle_snapped(&mut self, ctx: &mut EventCtx, scroll_velocity: f64) {
let stride = self.solver(self.offset).stride();
let target = physics::snap_offset(self.offset, stride, scroll_velocity, self.max_offset());
self.report(ctx, target);
if self.controlled {
self.start_settle();
return;
}
self.index = physics::nearest_item(target, stride).min(self.pods.len().saturating_sub(1));
self.settle_to(target);
}
fn wheel(&mut self, ctx: &mut EventCtx, delta: &ScrollDelta) -> EventResult {
let (dx, dy) = match delta {
ScrollDelta::Lines(x, y) => (x * WHEEL_LINE_PX, y * WHEEL_LINE_PX),
ScrollDelta::Pixels(x, y) => (*x, *y),
};
let main = match self.axis {
CarouselAxis::Horizontal if dx != 0.0 => dx,
CarouselAxis::Horizontal | CarouselAxis::Vertical => dy,
};
self.set_offset(self.offset + main);
self.settle_snapped(ctx, 0.0);
ctx.request_redraw();
EventResult::Handled
}
fn forward_to_item(
&mut self,
ctx: &mut EventCtx,
event: &InputEvent,
item: Option<usize>,
) -> EventResult {
match item.and_then(|index| self.pods.get_mut(index)) {
Some(pod) => pod.event_child(ctx, event),
None => EventResult::Ignored,
}
}
fn advance_settle(&mut self, ctx: &mut PaintCtx, reduce_motion: bool) {
if self.drag.is_some() || !self.settle.is_animating() {
return;
}
let next = if reduce_motion {
self.settle.stop();
self.settle_to
} else {
if self.settle.advance(ctx.frame_time()) {
ctx.request_frame();
}
let ramp = self.settle.value_clamped();
self.settle_from + (self.settle_to - self.settle_from) * ramp
};
if (next - self.offset).abs() > OFFSET_EPSILON {
self.set_offset(next);
}
}
}
fn content_main(axis: CarouselAxis, size: Size) -> f64 {
match axis {
CarouselAxis::Horizontal => size.width,
CarouselAxis::Vertical => size.height,
}
}
fn content_cross(axis: CarouselAxis, size: Size) -> f64 {
match axis {
CarouselAxis::Horizontal => size.height,
CarouselAxis::Vertical => size.width,
}
}
fn axis_point(axis: CarouselAxis, main: f64, cross: f64) -> Point {
match axis {
CarouselAxis::Horizontal => Point::new(main, cross),
CarouselAxis::Vertical => Point::new(cross, main),
}
}
fn resolve_radius(explicit: Option<f64>, theme: Option<&Theme>) -> f64 {
explicit.unwrap_or(match theme {
Some(theme) => theme.shape.extra_large,
None => CAROUSEL_ITEM_RADIUS,
})
}
fn resolve_content_color(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().on_surface,
None => ON_SURFACE,
}
}
impl Widget for CarouselWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let max = bc.max();
let width = self
.width
.unwrap_or(if max.width.is_finite() {
max.width
} else {
0.0
})
.max(0.0);
let height = self
.height
.unwrap_or(if max.height.is_finite() {
max.height
} else {
0.0
})
.max(0.0);
let size = Size::new(width, height);
(self.main, self.cross) = match self.axis {
CarouselAxis::Horizontal => (width, height),
CarouselAxis::Vertical => (height, width),
};
let content = self.content_size();
for pod in self.pods.iter_mut() {
pod.layout_child(ctx, &BoxConstraints::tight(content));
}
if self.drag.is_none() && !self.settle.is_animating() {
self.index = self.index.min(self.pods.len().saturating_sub(1));
let target = self.offset_for(self.index);
self.offset = target;
}
self.set_offset(self.offset);
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.last_frame_time = ctx.frame_time();
let (radius, content_color, reduce_motion) = {
let theme = Theme::from_paint_ctx(ctx);
(
resolve_radius(self.item_radius, theme),
resolve_content_color(theme),
theme.is_some_and(|t| t.motion.reduce_motion),
)
};
self.advance_settle(ctx, reduce_motion);
let origin = ctx.origin();
let size = ctx.size();
scene.push_clip(origin, size);
let slots = std::mem::take(&mut self.slots);
for slot in &slots {
let local = self.item_rect(slot);
if local.width() <= 0.0 || local.height() <= 0.0 {
continue;
}
let at = Point::new(origin.x + local.x0, origin.y + local.y0);
let item_size = local.size();
scene.push_clip_rounded(at, item_size, radius);
if let Some(pod) = self.pods.get_mut(slot.index) {
pod.paint_child(ctx, scene);
}
if self.pressed == Some(slot.index) {
self.state.paint(
ctx,
scene,
Rect::from_origin_size(at, item_size),
radius,
content_color,
);
}
scene.pop_clip();
}
self.slots = slots;
scene.pop_clip();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
self.event_at(ctx, event, self.event_time_ms())
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let pods = &self.pods;
ctx.push_container(
Role::Group,
|_| {},
|ctx| {
for pod in pods {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(pods);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::{PointerButton, PointerEvent, PointerPhase, any};
use std::any::Any;
const WINDOW: Size = Size::new(600.0, 200.0);
const STRIDE: f64 = 300.0;
#[derive(Default)]
struct Reports {
changes: Vec<CarouselChange>,
taps: Vec<usize>,
presses: usize,
}
struct Item;
struct ItemWidget;
impl<S: 'static> View<S> for Item {
type Element = ItemWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> ItemWidget {
ItemWidget
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut ItemWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for ItemWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.max()
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Pointer(p) = event
&& p.phase == PointerPhase::Down
{
ctx.state_mut::<Reports>().presses += 1;
ctx.request_focus();
return EventResult::Handled;
}
EventResult::Ignored
}
}
#[derive(Default)]
struct RecordingScene {
clips: Vec<(Point, Size)>,
rounded: Vec<(Point, Size, f64)>,
rrects: Vec<(Point, Size, f64, Color)>,
}
impl PaintScene for RecordingScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn push_clip(&mut self, origin: Point, size: Size) {
self.clips.push((origin, size));
}
fn push_clip_rounded(&mut self, origin: Point, size: Size, radius: f64) {
self.rounded.push((origin, size, radius));
}
fn pop_clip(&mut self) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
}
fn items(count: usize) -> Vec<AnyView<Reports>> {
(0..count).map(|_| any(Item)).collect()
}
fn view(count: usize) -> CarouselView<Reports> {
contained_carousel(items(count))
.on_change(|s: &mut Reports, change: CarouselChange| s.changes.push(change))
.on_tap(|s: &mut Reports, index: usize| s.taps.push(index))
}
fn build(v: &CarouselView<Reports>) -> CarouselWidget {
let mut counter = 0u64;
View::<Reports>::build(v, &mut BuildCtx::new(&mut counter))
}
fn laid_out(v: &CarouselView<Reports>) -> CarouselWidget {
let mut w = build(v);
let mut ctx = LayoutCtx::new();
w.layout(&mut ctx, &BoxConstraints::loose(WINDOW));
w
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Secondary,
})
}
fn dispatch(w: &mut CarouselWidget, state: &mut Reports, event: &InputEvent, t_ms: f64) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
w.event_at(&mut ctx, event, t_ms);
}
fn paint(w: &mut CarouselWidget) -> RecordingScene {
let mut ctx = PaintCtx::new(Point::ZERO, WINDOW);
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
scene
}
fn finish_settle(w: &mut CarouselWidget) {
w.settle.advance(FrameTime::from_nanos(0));
w.settle.advance(FrameTime::from_nanos(2_000_000_000));
let value = w.settle.value_clamped();
let next = w.settle_from + (w.settle_to - w.settle_from) * value;
w.set_offset(next);
}
#[test]
fn every_child_is_laid_out_at_the_largest_slot_less_its_padding() {
let w = laid_out(&view(6));
for pod in &w.pods {
assert_eq!(
pod.size(),
Size::new(
STRIDE - CAROUSEL_ITEM_PADDING * 2.0,
WINDOW.height - CAROUSEL_ITEM_PADDING * 2.0
),
"a slot's extent changes with the scroll; the child's does not"
);
}
}
#[test]
fn the_visible_slots_are_the_layouts_own_weights() {
let w = laid_out(&view(6));
assert_eq!(
w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
vec![300.0, 240.0, 60.0]
);
}
#[test]
fn items_beyond_the_viewport_are_parked_outside_the_carousels_bounds() {
let w = laid_out(&view(6));
for pod in &w.pods[3..] {
assert!(
pod.origin().x >= WINDOW.width || pod.origin().x + pod.size().width <= 0.0,
"an invisible item must be unhittable, not merely unpainted"
);
}
}
#[test]
fn a_vertical_carousel_slots_along_the_y_axis() {
let w = laid_out(&view(6).axis(CarouselAxis::Vertical));
assert_eq!(
w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
vec![100.0, 80.0, 20.0]
);
assert_eq!(w.pods[0].origin().y, CAROUSEL_ITEM_PADDING);
assert_eq!(
w.pods[0].size().width,
WINDOW.width - CAROUSEL_ITEM_PADDING * 2.0
);
}
#[test]
fn an_unbounded_axis_collapses_unless_a_size_is_pinned() {
let mut w = build(&view(6));
let mut ctx = LayoutCtx::new();
let size = w.layout(
&mut ctx,
&BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
);
assert_eq!(size, Size::ZERO);
let mut w = build(&view(6).width(400.0).height(120.0));
let mut ctx = LayoutCtx::new();
let size = w.layout(
&mut ctx,
&BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
);
assert_eq!(size, Size::new(400.0, 120.0));
}
#[test]
fn the_hero_layouts_focal_item_is_offset_from_the_leading_one() {
let w = laid_out(&view(6).layout(CarouselLayout::Hero));
assert_eq!(w.focal_slot(), 1, "`[2, 6, 2]`'s max weight is in slot 1");
assert_eq!(w.leading_index(), 0);
assert_eq!(w.focal_index(), 1);
let start = laid_out(
&view(6)
.layout(CarouselLayout::Hero)
.hero_alignment(HeroAlignment::Start),
);
assert_eq!(start.focal_slot(), 0, "`[8, 2]` leads with its max weight");
}
#[test]
fn an_uncontained_carousel_uses_a_fixed_extent_and_the_list_scroll_bound() {
let w = laid_out(&uncontained_carousel(items(6), 200.0));
assert_eq!(
w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
vec![200.0, 200.0, 200.0]
);
assert_eq!(w.max_offset(), 6.0 * 200.0 - WINDOW.width);
assert_eq!(
w.focal_index(),
w.leading_index(),
"no focal slot to shift to"
);
}
#[test]
fn the_initial_item_names_the_focal_item_not_the_leading_one() {
let w = laid_out(&view(8).layout(CarouselLayout::Hero).initial_item(3));
assert_eq!(
w.leading_index(),
2,
"`[2, 6, 2]` shifts the focal item by 1"
);
assert_eq!(w.focal_index(), 3);
assert_eq!(
w.offset(),
2.0 * 120.0,
"and rests on the leading slot item"
);
}
#[test]
fn consume_max_weight_makes_the_slot_item_the_focal_one() {
let w = laid_out(
&view(8)
.layout(CarouselLayout::Hero)
.consume_max_weight(true)
.initial_item(3),
);
assert_eq!(w.focal_index(), 3);
assert_eq!(w.leading_index(), 2);
assert_eq!(w.offset(), 3.0 * 120.0);
let at_rest = laid_out(
&view(8)
.layout(CarouselLayout::Hero)
.consume_max_weight(true),
);
assert_eq!(
at_rest.focal_index(),
0,
"item 0 occupies the largest slot at offset 0"
);
}
#[test]
fn each_visible_item_is_painted_inside_its_own_rounded_clip() {
let mut w = laid_out(&view(6));
let scene = paint(&mut w);
assert_eq!(scene.clips.len(), 1, "the carousel clips its own viewport");
assert_eq!(scene.rounded.len(), 3, "one rounded clip per visible item");
let (origin, size, radius) = scene.rounded[0];
assert_eq!(
origin,
Point::new(CAROUSEL_ITEM_PADDING, CAROUSEL_ITEM_PADDING)
);
assert_eq!(
size,
Size::new(
STRIDE - CAROUSEL_ITEM_PADDING * 2.0,
WINDOW.height - CAROUSEL_ITEM_PADDING * 2.0
)
);
assert_eq!(radius, CAROUSEL_ITEM_RADIUS);
}
#[test]
fn a_theme_resolves_the_item_radius_and_an_explicit_value_wins() {
let theme = crate::baseline();
let mut w = laid_out(&view(6));
let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert_eq!(scene.rounded[0].2, theme.shape.extra_large);
let mut w = laid_out(&view(6).item_radius(4.0));
let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert_eq!(scene.rounded[0].2, 4.0);
}
#[test]
fn a_pressed_item_paints_the_state_layer_over_its_own_clip() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 100.0, 100.0),
0.0,
);
let scene = paint(&mut w);
assert_eq!(scene.rrects.len(), 1, "exactly the pressed item's overlay");
assert_eq!(
scene.rrects[0].0,
Point::new(CAROUSEL_ITEM_PADDING, CAROUSEL_ITEM_PADDING)
);
assert_eq!(
scene.rrects[0].3.components[3],
crate::interaction::PRESSED_OPACITY
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 100.0, 100.0),
16.0,
);
let scene = paint(&mut w);
assert!(scene.rrects.is_empty(), "the overlay clears on release");
}
#[test]
fn a_press_reaches_the_item_under_it_and_a_tap_reports_that_item() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 400.0, 100.0),
0.0,
);
assert_eq!(
state.presses, 1,
"the item under the pointer sees the press"
);
assert_eq!(w.pressed, Some(1));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 400.0, 100.0),
16.0,
);
assert_eq!(state.taps, vec![1]);
assert!(w.pressed.is_none());
}
#[test]
fn a_release_outside_the_pressed_item_reports_no_tap() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 100.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 400.0, 100.0),
16.0,
);
assert!(state.taps.is_empty());
}
#[test]
fn a_secondary_press_reaches_the_item_but_arms_no_drag() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Down, 100.0, 100.0),
0.0,
);
assert!(w.drag.is_none());
assert!(w.pressed.is_none());
}
#[test]
fn a_drag_past_the_slop_takes_the_gesture_over_and_moves_the_content() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 400.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 398.0, 100.0),
8.0,
);
assert_eq!(w.offset, 0.0);
assert_eq!(w.pressed, Some(1), "the press survives a sub-slop wobble");
let past = 400.0 - TOUCH_SLOP - 5.0;
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, past, 100.0),
16.0,
);
assert!(w.pressed.is_none());
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 250.0, 100.0),
24.0,
);
assert_eq!(w.offset, 150.0, "the content follows the finger");
}
#[test]
fn a_slow_release_mid_item_settles_onto_the_nearest_boundary() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 400.0, 100.0),
400.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 340.0, 100.0),
900.0,
);
assert_eq!(w.offset, 160.0);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 340.0, 100.0),
1000.0,
);
assert_eq!(w.settle_to, STRIDE, "settles forward to the item boundary");
finish_settle(&mut w);
assert_eq!(w.offset, STRIDE);
assert_eq!(w.leading_index(), 1);
}
#[test]
fn a_slow_release_before_the_halfway_mark_settles_back() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 440.0, 100.0),
500.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 420.0, 100.0),
1000.0,
);
assert_eq!(w.offset, 80.0);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 420.0, 100.0),
1100.0,
);
assert_eq!(w.settle_to, 0.0);
finish_settle(&mut w);
assert_eq!(w.offset, 0.0);
assert_eq!(w.leading_index(), 0);
}
#[test]
fn a_short_fast_fling_still_advances_one_whole_item() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 470.0, 100.0),
8.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 440.0, 100.0),
16.0,
);
assert_eq!(w.offset, 60.0);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 440.0, 100.0),
16.0,
);
assert_eq!(w.settle_to, STRIDE, "a fling advances one item");
finish_settle(&mut w);
assert_eq!(w.leading_index(), 1);
}
#[test]
fn a_backward_fling_returns_to_the_previous_item() {
let mut w = laid_out(&view(6));
w.set_offset(STRIDE);
w.index = 1;
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 300.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 330.0, 100.0),
8.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 360.0, 100.0),
16.0,
);
assert_eq!(
w.offset, 240.0,
"only 60px back — a slow release would stay"
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 360.0, 100.0),
16.0,
);
assert_eq!(w.settle_to, 0.0);
finish_settle(&mut w);
assert_eq!(w.leading_index(), 0);
}
#[test]
fn a_scroll_never_leaves_the_scrollable_range() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 100.0, 100.0),
8.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, -5000.0, 100.0),
16.0,
);
assert_eq!(w.offset, w.max_offset());
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 9000.0, 100.0),
24.0,
);
assert_eq!(w.offset, 0.0);
}
#[test]
fn a_carousel_with_nothing_to_scroll_never_takes_a_gesture_over() {
let mut w = laid_out(&view(3));
assert!(!w.scrollable());
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 100.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, -500.0, 100.0),
16.0,
);
assert_eq!(w.offset, 0.0);
assert_eq!(w.pressed, Some(0), "the press stays with the item");
}
#[test]
fn a_wheel_tick_moves_the_content_and_settles_onto_a_boundary() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
let wheel = InputEvent::Scroll {
position: Point::new(100.0, 100.0),
delta: frust::authoring::ScrollDelta::Pixels(180.0, 0.0),
};
dispatch(&mut w, &mut state, &wheel, 0.0);
assert_eq!(w.offset, 180.0, "the content follows the wheel");
assert_eq!(w.settle_to, STRIDE, "and settles onto the nearest boundary");
finish_settle(&mut w);
assert_eq!(w.leading_index(), 1);
}
#[test]
fn a_press_on_another_item_clears_the_previous_ones_focus_link() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 100.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 100.0, 100.0),
16.0,
);
assert!(
w.pods[0].is_focused(),
"the item claimed focus on its press"
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 400.0, 100.0),
32.0,
);
assert!(!w.pods[0].is_focused(), "a press elsewhere blurs it");
assert!(w.pods[1].is_focused());
}
#[test]
fn a_cancel_clears_the_gesture_and_settles_back_without_reporting() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 350.0, 100.0),
16.0,
);
let reported = state.changes.len();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Cancel, 350.0, 100.0),
24.0,
);
assert!(w.drag.is_none());
assert!(w.pressed.is_none());
assert_eq!(w.settle_to, 0.0, "back onto the item it started from");
assert_eq!(state.changes.len(), reported, "a cancel reports nothing");
assert!(state.taps.is_empty());
}
#[test]
fn a_scroll_reports_the_leading_and_focal_item_at_the_halfway_mark() {
let mut w = laid_out(&view(8).layout(CarouselLayout::Hero));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 470.0, 100.0),
100.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 450.0, 100.0),
200.0,
);
assert!(state.changes.is_empty(), "50px in: still nearest to item 0");
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 420.0, 100.0),
400.0,
);
assert_eq!(
state.changes.last().copied(),
Some(CarouselChange {
leading_index: 1,
focal_index: 2,
item_count: 8
})
);
}
#[test]
fn a_controlled_carousel_reports_the_request_and_moves_nothing() {
let mut w = laid_out(&view(6).selected(1));
assert!(w.controlled);
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 400.0, 100.0),
400.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 340.0, 100.0),
900.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 340.0, 100.0),
1000.0,
);
assert!(!state.changes.is_empty(), "the request is still reported");
assert_eq!(
w.settle_to,
w.offset_for(w.index),
"it settles back onto the app-confirmed item"
);
}
#[test]
fn a_rebuild_with_a_new_selection_settles_onto_it() {
let mut counter = 0u64;
let mut ctx = BuildCtx::new(&mut counter);
let first = view(8).selected(0);
let mut w = View::<Reports>::build(&first, &mut ctx);
let mut layout_ctx = LayoutCtx::new();
w.layout(&mut layout_ctx, &BoxConstraints::loose(WINDOW));
let next = view(8).selected(3);
View::<Reports>::rebuild(&next, &first, &mut w, &mut ctx);
assert_eq!(w.leading_index(), 3);
finish_settle(&mut w);
assert_eq!(w.offset, STRIDE * 3.0);
}
#[test]
fn reduce_motion_lands_on_the_target_without_asking_for_a_frame() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 400.0, 100.0),
400.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 340.0, 100.0),
900.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 340.0, 100.0),
1000.0,
);
let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert_eq!(w.offset, STRIDE, "jumped straight to the target");
assert!(!ctx.needs_frame(), "and asked for no continuation frame");
}
#[test]
fn a_settle_asks_for_the_next_frame_while_it_runs() {
let mut w = laid_out(&view(6));
let mut state = Reports::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 500.0, 100.0),
0.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 400.0, 100.0),
400.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 340.0, 100.0),
900.0,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 340.0, 100.0),
1000.0,
);
let mut ctx = PaintCtx::new(Point::ZERO, WINDOW);
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert!(ctx.needs_frame());
}
#[test]
fn an_empty_carousel_lays_out_and_paints_without_panicking() {
let mut w = laid_out(&view(0));
assert!(w.slots.is_empty());
let scene = paint(&mut w);
assert!(scene.rounded.is_empty());
let mut state = Reports::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0), 0.0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0), 16.0);
assert!(state.taps.is_empty());
}
#[test]
fn a_zero_sized_carousel_degrades_instead_of_dividing_by_its_viewport() {
let mut w = build(&view(6));
let mut ctx = LayoutCtx::new();
let size = w.layout(&mut ctx, &BoxConstraints::tight(Size::ZERO));
assert_eq!(size, Size::ZERO);
assert!(w.slots.is_empty());
assert_eq!(w.max_offset(), 0.0);
let scene = paint(&mut w);
assert!(scene.rounded.is_empty());
}
}