Skip to main content

tessera_components/
carousel.rs

1//! Adaptive carousels with smoothly masked, full-size content.
2//!
3//! ## Usage
4//!
5//! Browse photos, media, and featured content in a scrollable collection.
6
7use std::{fmt, sync::Arc};
8
9use tessera_foundation::gesture::{DragAxis, DragRecognizer, DragSettings};
10use tessera_ui::{
11    AccessibilityActionHandler, AccessibilityNode, AxisConstraint, ClipRegion, ClipShape,
12    ComputedData, Constraint, Dp, DrawModifierContent, DrawModifierContext, DrawModifierNode,
13    FocusManager, FocusProperties, FocusRequester, FocusScopeNode, KeyboardInput,
14    KeyboardInputModifierNode, LayoutResult, MeasurementError, Modifier, PointerEventPass,
15    PointerInput, PointerInputModifierNode, Px, PxPosition, RenderSlotWith, SemanticsModifierNode,
16    State,
17    accesskit::{Action, Role},
18    dp::scale_factor,
19    key,
20    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
21    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
22    normalize_platform_scroll_delta, receive_frame_nanos, remember, tessera, winit,
23};
24
25use crate::{
26    animation::Animatable,
27    modifier::{ModifierExt as _, SemanticsArgs},
28    shape_def::{ResolvedShape, RoundedCorner, Shape},
29};
30
31use strategy::{Strategy, StrategyConfig};
32use variable::normalized_ratio;
33
34mod focus;
35mod strategy;
36mod variable;
37
38/// Arrangement of large and partially masked carousel items.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum CarouselVariant {
41    /// Fit a mixture of large, medium, and small items to the viewport.
42    #[default]
43    MultiBrowse,
44    /// Preserve a fixed item size with a partially visible trailing item.
45    Uncontained,
46    /// Preserve independently sized portrait, landscape, and square items.
47    UncontainedMultiAspectRatio,
48    /// Emphasize large items with a small trailing item.
49    Hero,
50    /// Center large items between small neighbors, shifting at collection ends.
51    CenteredHero,
52    /// Show one edge-to-edge item per page in a vertical feed.
53    FullScreen,
54}
55
56/// Behavior after a drag ends.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum CarouselFlingBehavior {
59    /// Snap with at most one additional item of momentum after release.
60    SingleAdvance,
61    /// Allow momentum to cross several items before snapping.
62    MultiAdvance,
63    /// Decelerate without snapping.
64    Free,
65}
66
67/// Information supplied to an item's content slot.
68///
69/// Read widths inside item content to respond to scrolling and viewport
70/// changes.
71#[derive(Clone, Copy, Default, PartialEq)]
72pub struct CarouselItemInfo {
73    /// Zero-based index of the item.
74    pub index: usize,
75    controller: Option<State<CarouselController>>,
76}
77
78impl fmt::Debug for CarouselItemInfo {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        formatter
81            .debug_struct("CarouselItemInfo")
82            .field("index", &self.index)
83            .finish_non_exhaustive()
84    }
85}
86
87impl CarouselItemInfo {
88    /// Returns the full content width, independent of the current mask.
89    pub fn full_width(&self) -> Dp {
90        self.widths().0
91    }
92
93    /// Returns the current visible width, updating content as the mask changes.
94    pub fn masked_width(&self) -> Dp {
95        self.widths().1
96    }
97
98    fn widths(&self) -> (Dp, Dp) {
99        self.controller
100            .map(|controller| {
101                controller.with(|state| {
102                    let density = state.config.map(|config| config.density).unwrap_or(1.0);
103                    let full = state
104                        .strategy
105                        .as_ref()
106                        .map(|strategy| strategy.item_width(self.index))
107                        .unwrap_or(180.0 * density);
108                    let width = state
109                        .masks
110                        .binary_search_by_key(&self.index, |mask| mask.0)
111                        .ok()
112                        .map(|position| {
113                            let (_, center, width) = state.masks[position];
114                            visible_mask_width(center, width, state.viewport_width)
115                        })
116                        .unwrap_or_else(|| if state.strategy.is_some() { 0.0 } else { full });
117                    let (full, width) = if state
118                        .config
119                        .is_some_and(|config| config.variant == CarouselVariant::FullScreen)
120                    {
121                        (state.viewport_width, state.viewport_width)
122                    } else {
123                        (full, width)
124                    };
125                    (Dp((full / density) as f64), Dp((width / density) as f64))
126                })
127            })
128            .unwrap_or_default()
129    }
130}
131
132fn visible_mask_width(center: f32, width: f32, viewport: f32) -> f32 {
133    ((center + width * 0.5).min(viewport.max(0.0)) - (center - width * 0.5).max(0.0)).max(0.0)
134}
135
136/// Persistent carousel scroll position and animation state.
137///
138/// Share through `State<CarouselController>` to navigate programmatically and
139/// observe the current item. Requests made before layout are applied on mount.
140#[derive(Clone)]
141pub struct CarouselController {
142    current_item: usize,
143    viewport_width: f32,
144    aspect_ratios: Arc<[f32]>,
145    aspect_revision: u64,
146    behavior: CarouselFlingBehavior,
147    motion: Animatable,
148    free_velocity: f32,
149    strategy: Option<Arc<Strategy>>,
150    config: Option<StrategyConfig>,
151    masks: Vec<(usize, f32, f32)>,
152    pending: Option<(usize, bool)>,
153    dragging: bool,
154    drag_velocity: f32,
155    last_input: Option<u64>,
156    last_frame: Option<u64>,
157    wheel_idle: Option<u64>,
158}
159
160impl Default for CarouselController {
161    fn default() -> Self {
162        Self::new(0)
163    }
164}
165
166impl CarouselController {
167    /// Creates a controller positioned at `initial_item`.
168    pub fn new(initial_item: usize) -> Self {
169        Self {
170            current_item: initial_item,
171            viewport_width: 0.0,
172            aspect_ratios: Arc::from([]),
173            aspect_revision: 0,
174            behavior: CarouselFlingBehavior::SingleAdvance,
175            motion: resting_motion(0.0),
176            free_velocity: 0.0,
177            strategy: None,
178            config: None,
179            masks: Vec::new(),
180            pending: Some((initial_item, false)),
181            dragging: false,
182            drag_velocity: 0.0,
183            last_input: None,
184            last_frame: None,
185            wheel_idle: None,
186        }
187    }
188
189    /// Returns the current or programmatically requested item index.
190    pub fn current_item(&self) -> usize {
191        self.current_item
192    }
193
194    /// Returns the current scroll offset in density-independent pixels.
195    pub fn scroll_offset(&self) -> Dp {
196        Dp((self.motion.value() / self.config.map(|config| config.density).unwrap_or(1.0)) as f64)
197    }
198
199    /// Returns whether scrolling or a snap animation is active.
200    pub fn is_scrolling(&self) -> bool {
201        self.dragging
202            || self.free_velocity.abs() > 0.1
203            || !self.motion.is_idle()
204            || self.wheel_idle.is_some()
205    }
206
207    /// Moves immediately to an item, clamping its index when layout is known.
208    pub fn jump_to_item(&mut self, item: usize) {
209        self.request(item, false);
210    }
211
212    /// Animates to an item, clamping its index when layout is known.
213    pub fn animate_to_item(&mut self, item: usize) {
214        self.request(item, true);
215    }
216
217    fn navigation_required(&self, item: usize) -> bool {
218        if item != self.current_item || self.dragging || self.needs_frame() {
219            return true;
220        }
221        match (&self.strategy, self.config) {
222            (Some(strategy), Some(config)) => {
223                (self.motion.value() - strategy.snap(item, config.count)).abs() > 0.01
224            }
225            _ => false,
226        }
227    }
228
229    fn request(&mut self, item: usize, animate: bool) {
230        let now = if animate {
231            tessera_ui::current_frame_nanos()
232        } else {
233            0
234        };
235        self.request_at(item, animate, now);
236    }
237
238    fn request_at(&mut self, item: usize, animate: bool, now: u64) {
239        self.dragging = false;
240        self.free_velocity = 0.0;
241        self.wheel_idle = None;
242        self.last_frame = animate.then_some(now);
243        self.current_item = self
244            .config
245            .map(|config| item.min(config.count.saturating_sub(1)))
246            .unwrap_or(item);
247        if let (Some(strategy), Some(config)) = (&self.strategy, self.config) {
248            let offset = strategy.snap(self.current_item, config.count);
249            if animate {
250                self.motion.set_target(offset);
251            } else {
252                self.motion = resting_motion(offset);
253            }
254            self.pending = None;
255            self.refresh_masks();
256        } else {
257            self.pending = Some((item, animate));
258        }
259    }
260
261    fn configure(&mut self, config: StrategyConfig) {
262        if self.config == Some(config) {
263            return;
264        }
265        self.config = Some(config);
266        if config.viewport <= 0.0
267            || config.count == 0
268            || (config.variant == CarouselVariant::UncontainedMultiAspectRatio
269                && config.item_height <= 0.0)
270        {
271            self.strategy = None;
272            self.motion = resting_motion(0.0);
273            self.masks.clear();
274            if config.count == 0 {
275                self.current_item = 0;
276            } else {
277                self.current_item = self.current_item.min(config.count - 1);
278                self.pending.get_or_insert((self.current_item, false));
279            }
280            self.stop_input();
281            return;
282        }
283        self.strategy = Some(Arc::new(Strategy::new(config, &self.aspect_ratios)));
284        let requested = self
285            .pending
286            .take()
287            .map(|request| request.0)
288            .unwrap_or(self.current_item);
289        self.request(requested, false);
290    }
291
292    fn refresh_masks(&mut self) {
293        self.masks = match (&self.strategy, self.config) {
294            (Some(strategy), Some(config)) => strategy.masks(self.motion.value(), config.count),
295            _ => Vec::new(),
296        };
297    }
298
299    fn nearest(&self, offset: f32) -> usize {
300        let (Some(strategy), Some(config)) = (&self.strategy, self.config) else {
301            return 0;
302        };
303        if config.count == 0 {
304            return 0;
305        }
306        if let Some(index) = strategy.variable_nearest(offset, config.count) {
307            return index;
308        }
309        let approximate = (offset / (strategy.item_size + strategy.spacing))
310            .round()
311            .max(0.0) as usize;
312        let radius = (strategy.viewport / strategy.item_size).ceil().max(1.0) as usize + 4;
313        let start = approximate.saturating_sub(radius).min(config.count - 1);
314        let end = approximate
315            .saturating_add(radius)
316            .saturating_add(1)
317            .min(config.count);
318        (start..end)
319            .min_by(|left, right| {
320                (strategy.snap(*left, config.count) - offset)
321                    .abs()
322                    .total_cmp(&(strategy.snap(*right, config.count) - offset).abs())
323            })
324            .unwrap_or(start)
325    }
326
327    fn move_by(&mut self, delta: f32) -> bool {
328        let (Some(strategy), Some(config)) = (&self.strategy, self.config) else {
329            return false;
330        };
331        let next = (self.motion.value() + delta).clamp(0.0, strategy.max_scroll(config.count));
332        if next == self.motion.value() {
333            return false;
334        }
335        self.motion = resting_motion(next);
336        self.current_item = self.nearest(next);
337        self.refresh_masks();
338        true
339    }
340
341    fn stop_input(&mut self) {
342        self.dragging = false;
343        self.drag_velocity = 0.0;
344        self.free_velocity = 0.0;
345        self.wheel_idle = None;
346        self.last_input = None;
347    }
348
349    fn end_drag(&mut self, behavior: CarouselFlingBehavior, now: u64, cancelled: bool) {
350        self.dragging = false;
351        let velocity = if cancelled
352            || self
353                .last_input
354                .is_none_or(|last| now.saturating_sub(last) > 100_000_000)
355        {
356            0.0
357        } else {
358            self.drag_velocity
359        };
360        self.drag_velocity = 0.0;
361        match behavior {
362            CarouselFlingBehavior::Free => {
363                self.free_velocity = velocity;
364                self.last_frame = Some(now);
365            }
366            _ => {
367                let current = self.nearest(self.motion.value());
368                let mut target = self.nearest(self.motion.value() + velocity / 6.0);
369                if behavior == CarouselFlingBehavior::SingleAdvance {
370                    target = target.clamp(current.saturating_sub(1), current.saturating_add(1));
371                }
372                let threshold = 50.0 * self.config.map(|config| config.density).unwrap_or(1.0);
373                if target == current && velocity.abs() > threshold {
374                    target = if velocity > 0.0 {
375                        current.saturating_add(1)
376                    } else {
377                        current.saturating_sub(1)
378                    };
379                }
380                self.motion = self.motion.with_velocity(velocity);
381                self.request_at(target, true, now);
382            }
383        }
384    }
385
386    fn needs_frame(&self) -> bool {
387        !self.dragging
388            && (self.free_velocity.abs() > 0.1
389                || !self.motion.is_idle()
390                || self.wheel_idle.is_some())
391    }
392
393    fn tick(&mut self, now: u64) {
394        let dt = self
395            .last_frame
396            .map(|last| now.saturating_sub(last) as f32 * 1.0e-9)
397            .unwrap_or(1.0 / 60.0)
398            .clamp(0.0, 0.05);
399        self.last_frame = Some(now);
400        if self.dragging {
401            return;
402        }
403        if dt <= 0.0 {
404            return;
405        }
406        if self
407            .wheel_idle
408            .is_some_and(|last| now.saturating_sub(last) >= 120_000_000)
409        {
410            self.wheel_idle = None;
411            if self.behavior != CarouselFlingBehavior::Free {
412                self.request_at(self.nearest(self.motion.value()), true, now);
413            }
414        }
415        if self.free_velocity.abs() > 0.1 {
416            let decay = (-6.0 * dt).exp();
417            let delta = self.free_velocity * (1.0 - decay) / 6.0;
418            if !self.move_by(delta) {
419                self.free_velocity = 0.0;
420            } else {
421                self.free_velocity *= decay;
422            }
423            if self.free_velocity.abs() <= 0.1 {
424                self.free_velocity = 0.0;
425            }
426        } else if !self.motion.is_idle() {
427            self.motion.tick(dt);
428            if let (Some(strategy), Some(config)) = (&self.strategy, self.config) {
429                let position = self
430                    .motion
431                    .value()
432                    .clamp(0.0, strategy.max_scroll(config.count));
433                if position != self.motion.value() {
434                    let target = self.motion.target();
435                    self.motion = resting_motion(position);
436                    self.motion.set_target(target);
437                }
438            }
439            if (self.motion.value() - self.motion.target()).abs() < 0.02
440                && self.motion.velocity().abs() < 0.2
441            {
442                self.motion = resting_motion(self.motion.target());
443            }
444            self.refresh_masks();
445        }
446    }
447}
448
449fn resting_motion(position: f32) -> Animatable {
450    Animatable::spring(position, 200.0f32.sqrt(), 1.0)
451}
452
453#[derive(Clone, PartialEq)]
454struct CarouselLayout {
455    controller: State<CarouselController>,
456    config: StrategyConfig,
457    height: Px,
458    indices: Vec<usize>,
459    offset: f32,
460    reverse: bool,
461}
462
463impl tessera_ui::LayoutModifierNode for CarouselLayout {
464    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
465        let parent = input.parent_constraint();
466        let width = parent.width().resolve_max().ok_or_else(|| {
467            MeasurementError::MeasureFnFailed("Carousel requires a bounded width".into())
468        })?;
469        let height = parent.height().clamp(self.height);
470        let vertical = self.config.variant == CarouselVariant::FullScreen;
471        let config = StrategyConfig {
472            viewport: if vertical {
473                height.to_f32()
474            } else {
475                width.to_f32()
476            },
477            padding: self
478                .config
479                .padding
480                .map(|padding| padding.min(width.to_f32().max(0.0))),
481            item_height: if self.config.variant == CarouselVariant::UncontainedMultiAspectRatio {
482                height.to_f32()
483            } else {
484                0.0
485            },
486            ..self.config
487        };
488        if self
489            .controller
490            .with(|state| state.config != Some(config) || state.viewport_width != width.to_f32())
491        {
492            self.controller.with_mut(|state| {
493                state.viewport_width = width.to_f32();
494                state.configure(config);
495            });
496        }
497        let (strategy, masks) = self
498            .controller
499            .with(|state| (state.strategy.clone(), state.masks.clone()));
500        let children = input.children();
501        let mut result = LayoutResult::new(ComputedData { width, height });
502        for (&child, &index) in children.iter().zip(&self.indices) {
503            let item_size = strategy
504                .as_ref()
505                .map(|strategy| strategy.item_width(index))
506                .unwrap_or(0.0);
507            let constraint = Constraint::new(
508                AxisConstraint::exact(if vertical {
509                    width
510                } else {
511                    Px(item_size.round() as i32)
512                }),
513                AxisConstraint::exact(height),
514            );
515            child.measure(input, &constraint)?;
516            let center = masks
517                .binary_search_by_key(&index, |mask| mask.0)
518                .ok()
519                .map(|position| masks[position].1)
520                .unwrap_or(
521                    if vertical {
522                        height.to_f32()
523                    } else {
524                        width.to_f32()
525                    } * 0.5,
526                );
527            let center = if self.reverse {
528                (if vertical {
529                    height.to_f32()
530                } else {
531                    width.to_f32()
532                }) - center
533            } else {
534                center
535            };
536            result.place_child(
537                child,
538                if vertical {
539                    PxPosition::from_f32_xy(0.0, center - item_size.round() * 0.5)
540                } else {
541                    PxPosition::from_f32_xy(center - item_size.round() * 0.5, 0.0)
542                },
543            );
544        }
545        Ok(result)
546    }
547
548    fn measure_eq(&self, other: &Self) -> bool {
549        self.config == other.config
550            && self.height == other.height
551            && self.indices == other.indices
552            && self.reverse == other.reverse
553    }
554
555    fn place_children(
556        &self,
557        input: &PlacementScope<'_>,
558        _inner: &PlacementResult,
559    ) -> Option<PlacementResult> {
560        let (strategy, masks) = self
561            .controller
562            .with(|state| (state.strategy.clone(), state.masks.clone()));
563        let children = input.children();
564        if children.len() != self.indices.len() {
565            return None;
566        }
567        let mut result = LayoutResult::new(input.size());
568        let vertical = self.config.variant == CarouselVariant::FullScreen;
569        for (&child, &index) in children.iter().zip(&self.indices) {
570            let item_size = strategy
571                .as_ref()
572                .map(|strategy| strategy.item_width(index))
573                .unwrap_or(0.0);
574            let center = masks
575                .binary_search_by_key(&index, |mask| mask.0)
576                .ok()
577                .map(|position| masks[position].1)
578                .unwrap_or(
579                    if vertical {
580                        input.size().height.to_f32()
581                    } else {
582                        input.size().width.to_f32()
583                    } * 0.5,
584                );
585            let center = if self.reverse {
586                (if vertical {
587                    input.size().height.to_f32()
588                } else {
589                    input.size().width.to_f32()
590                }) - center
591            } else {
592                center
593            };
594            result.place_child(
595                child,
596                if vertical {
597                    PxPosition::from_f32_xy(0.0, center - item_size.round() * 0.5)
598                } else {
599                    PxPosition::from_f32_xy(center - item_size.round() * 0.5, 0.0)
600                },
601            );
602        }
603        Some(result.into_placement_result())
604    }
605}
606
607#[derive(Clone, Copy)]
608struct ItemMask {
609    controller: State<CarouselController>,
610    index: usize,
611    shape: Shape,
612}
613
614impl DrawModifierNode for ItemMask {
615    fn draw(&self, ctx: &mut DrawModifierContext<'_, '_>, content: &mut dyn DrawModifierContent) {
616        let width = self.controller.with(|state| {
617            state
618                .masks
619                .binary_search_by_key(&self.index, |mask| mask.0)
620                .ok()
621                .map(|position| state.masks[position].2)
622                .unwrap_or(0.0)
623        });
624        let vertical = self.controller.with(|state| {
625            state
626                .config
627                .is_some_and(|config| config.variant == CarouselVariant::FullScreen)
628        });
629        let mut metadata = ctx.render_input.metadata_mut();
630        let size = metadata.computed_data().expect("measured carousel item");
631        let shape = match self.shape.resolve_for_size(size.into()) {
632            ResolvedShape::Rounded {
633                corner_radii,
634                corner_g2,
635            } => ClipShape::RoundedRectangle {
636                radii: corner_radii,
637                exponents: corner_g2,
638            },
639            ResolvedShape::Ellipse => ClipShape::Ellipse,
640        };
641        metadata.set_clip_region(ClipRegion {
642            position: if vertical {
643                PxPosition::from_f32_xy(0.0, (size.height.to_f32() - width) * 0.5)
644            } else {
645                PxPosition::from_f32_xy((size.width.to_f32() - width) * 0.5, 0.0)
646            },
647            size: if vertical {
648                [size.width.to_f32(), width]
649            } else {
650                [width, size.height.to_f32()]
651            },
652            shape,
653        });
654        content.draw(ctx.render_input);
655    }
656}
657
658struct CarouselInput {
659    controller: State<CarouselController>,
660    recognizer: State<DragRecognizer>,
661    focus: FocusRequester,
662    enabled: bool,
663    reverse: bool,
664    vertical: bool,
665    behavior: CarouselFlingBehavior,
666    navigation: focus::Navigation,
667}
668
669impl PointerInputModifierNode for CarouselInput {
670    fn on_pointer_input(&self, input: PointerInput<'_>) {
671        if !self.enabled || input.pass != PointerEventPass::Main {
672            return;
673        }
674        let inside = input.cursor_position_rel.is_some_and(|point| {
675            point.x >= 0.0
676                && point.y >= 0.0
677                && point.x < input.computed_data.width.to_f32()
678                && point.y < input.computed_data.height.to_f32()
679        });
680        let now = tessera_ui::current_frame_nanos();
681        if inside {
682            for change in input.pointer_changes.iter_mut() {
683                if change.is_consumed() {
684                    continue;
685                }
686                if matches!(
687                    change.content,
688                    tessera_ui::CursorEventContent::Pressed(tessera_ui::PressKeyEventType::Left)
689                ) {
690                    self.controller.with_mut(|state| {
691                        state.free_velocity = 0.0;
692                        state.wheel_idle = None;
693                        state.motion = resting_motion(state.motion.value());
694                        state.current_item = state.nearest(state.motion.value());
695                    });
696                }
697                if let tessera_ui::CursorEventContent::Scroll(scroll) = &mut change.content {
698                    let (delta_x, delta_y) = normalize_platform_scroll_delta(
699                        scroll.delta_x,
700                        scroll.delta_y,
701                        scroll.unit,
702                        scroll.source,
703                    );
704                    let delta = if self.vertical { delta_y } else { delta_x };
705                    if delta.abs() > 0.001 {
706                        let moved = self.controller.with_mut(|state| {
707                            state.free_velocity = 0.0;
708                            let moved = state.move_by(if self.reverse { delta } else { -delta });
709                            if moved && self.behavior != CarouselFlingBehavior::Free {
710                                state.wheel_idle = Some(now);
711                            }
712                            moved
713                        });
714                        if moved {
715                            let remaining = if self.vertical {
716                                scroll.delta_y = 0.0;
717                                scroll.delta_x
718                            } else {
719                                scroll.delta_x = 0.0;
720                                scroll.delta_y
721                            };
722                            if remaining.abs() < 0.001 {
723                                change.consume();
724                            }
725                        }
726                    }
727                }
728            }
729        }
730        let drag = self.recognizer.with_mut(|recognizer| {
731            recognizer.update(input.pass, input.pointer_changes.as_mut_slice(), inside)
732        });
733        if drag.started {
734            self.focus.request_focus();
735            self.controller.with_mut(|state| {
736                state.stop_input();
737                state.dragging = true;
738                state.motion = resting_motion(state.motion.value());
739            });
740        }
741        if drag.updated {
742            self.controller.with_mut(|state| {
743                let delta = if self.vertical {
744                    input.local_delta(0.0, drag.delta_y.to_f32()).1
745                } else {
746                    input.local_delta(drag.delta_x.to_f32(), 0.0).0
747                } * if self.reverse { 1.0 } else { -1.0 };
748                let dt = state
749                    .last_input
750                    .map(|last| now.saturating_sub(last) as f32 * 1.0e-9)
751                    .unwrap_or(1.0 / 60.0)
752                    .clamp(0.004, 0.1);
753                let window = input
754                    .local_to_window
755                    .map_vector(PxPosition::from_f32_xy(delta, delta));
756                let window_delta = if self.vertical { window.y } else { window.x };
757                state.drag_velocity = state.drag_velocity * 0.6 + window_delta / dt * 0.4;
758                state.last_input = Some(now);
759                state.move_by(delta);
760            });
761        }
762        let cancelled = !self.recognizer.with(DragRecognizer::is_dragging)
763            && self.controller.with(|state| state.dragging)
764            && !drag.ended;
765        if drag.ended || cancelled {
766            self.controller.with_mut(|state| {
767                let local = input.local_delta(state.drag_velocity, state.drag_velocity);
768                state.drag_velocity = if self.vertical { local.1 } else { local.0 };
769                state.end_drag(self.behavior, now, cancelled);
770            });
771        }
772    }
773}
774
775impl KeyboardInputModifierNode for CarouselInput {
776    fn on_keyboard_input(&self, mut input: KeyboardInput<'_>) {
777        use winit::{event::ElementState, keyboard::Key};
778
779        if !self.enabled
780            || input.key_modifiers.control_key()
781            || input.key_modifiers.alt_key()
782            || input.key_modifiers.super_key()
783        {
784            return;
785        }
786        for event in input.keyboard_events.iter() {
787            if event.state != ElementState::Pressed {
788                continue;
789            }
790            if let Key::Named(key) = event.logical_key
791                && self.navigation.navigate(
792                    self.controller,
793                    key,
794                    self.vertical,
795                    self.reverse,
796                    input.key_modifiers.shift_key(),
797                )
798            {
799                input.block_keyboard();
800                break;
801            }
802        }
803    }
804}
805
806struct CarouselSemantics {
807    controller: State<CarouselController>,
808    enabled: bool,
809    reverse: bool,
810    vertical: bool,
811}
812
813impl SemanticsModifierNode for CarouselSemantics {
814    fn apply(
815        &self,
816        node: &mut AccessibilityNode,
817        handler: &mut Option<AccessibilityActionHandler>,
818    ) {
819        let (item, count, extent) = self.controller.with(|state| {
820            (
821                state.current_item,
822                state.config.map(|config| config.count).unwrap_or(0),
823                state
824                    .strategy
825                    .as_ref()
826                    .map(|strategy| {
827                        strategy.max_scroll(state.config.map(|config| config.count).unwrap_or(0))
828                    })
829                    .unwrap_or(0.0),
830            )
831        });
832        node.value = (count > 0).then(|| format!("{} of {count}", item + 1));
833        if !self.enabled || extent <= 0.0 {
834            *handler = None;
835            return;
836        }
837        let (mut backward, mut forward) = if self.vertical {
838            (Action::ScrollUp, Action::ScrollDown)
839        } else {
840            (Action::ScrollLeft, Action::ScrollRight)
841        };
842        if self.reverse {
843            std::mem::swap(&mut backward, &mut forward);
844        }
845        if item > 0 {
846            node.actions.push(backward);
847        }
848        if item + 1 < count {
849            node.actions.push(forward);
850        }
851        let controller = self.controller;
852        *handler = Some(Box::new(move |action| {
853            let advance = if action == forward {
854                true
855            } else if action == backward {
856                false
857            } else {
858                return;
859            };
860            controller.with_mut(|state| {
861                let next = if advance {
862                    state.current_item.saturating_add(1)
863                } else {
864                    state.current_item.saturating_sub(1)
865                };
866                state.animate_to_item(next);
867            });
868        }));
869    }
870}
871
872#[derive(Clone, PartialEq)]
873struct CarouselRenderState {
874    count: usize,
875    shape: Shape,
876    content: Option<RenderSlotWith<CarouselItemInfo>>,
877}
878
879fn item_indices(state: &CarouselController, count: usize, retained: Option<usize>) -> Vec<usize> {
880    let mut indices = if state.strategy.is_some() {
881        state
882            .masks
883            .iter()
884            .map(|mask| mask.0)
885            .filter(|index| *index < count)
886            .collect::<Vec<_>>()
887    } else {
888        (state.current_item.saturating_sub(2)..state.current_item.saturating_add(6).min(count))
889            .collect()
890    };
891    if let Some(retained) = retained.filter(|index| *index < count)
892        && let Err(position) = indices.binary_search(&retained)
893    {
894        indices.insert(position, retained);
895    }
896    indices
897}
898
899#[tessera]
900fn carousel_item(
901    index: usize,
902    controller: State<CarouselController>,
903    render_state: State<CarouselRenderState>,
904    navigation: focus::Navigation,
905) {
906    let scope = remember(FocusScopeNode::new).get();
907    let _lease = remember(move || focus::ItemLease { navigation, index });
908    navigation.items.with_mut(|items| {
909        items.insert(index, scope);
910    });
911    let shape = render_state.with(|state| state.shape);
912    let vertical = controller.with(|state| {
913        state
914            .config
915            .is_some_and(|config| config.variant == CarouselVariant::FullScreen)
916    });
917    layout()
918        .modifier(
919            Modifier::new()
920                .focus_scope_with(scope)
921                .focus_properties(FocusProperties::new().skip_traversal(true))
922                .on_layout_coordinates(move |_| {
923                    navigation.finish(controller.with(|state| !state.needs_frame()));
924                })
925                .semantics(SemanticsArgs {
926                    role: Some(Role::ListItem),
927                    merge_descendants: true,
928                    collection_item_info: Some(if vertical {
929                        (index, 1, 0, 1, false)
930                    } else {
931                        (0, 1, index, 1, false)
932                    }),
933                    ..Default::default()
934                })
935                .push_draw(ItemMask {
936                    controller,
937                    index,
938                    shape,
939                }),
940        )
941        .child(move || {
942            if let Some(content) = render_state.with(|state| state.content) {
943                let info = CarouselItemInfo {
944                    index,
945                    controller: Some(controller),
946                };
947                content.render(info);
948            }
949        });
950}
951
952/// # carousel
953///
954/// Browse media collections with adaptive masks and scroll snapping.
955///
956/// ## Usage
957///
958/// Browse photo albums, recommendations, and featured media collections.
959///
960/// ## Parameters
961///
962/// - `item_count` — number of items in the collection.
963/// - `modifier`, `height` — viewport placement, bounded width, and height
964///   (180dp by default).
965/// - `variant` — multi-browse, uncontained, mixed-aspect uncontained, hero,
966///   centered hero, or full-screen arrangement.
967/// - `preferred_item_width` — target width; defaults to 180dp for
968///   multi-browse/uncontained and the viewport for hero layouts; ignored in
969///   full-screen and mixed-aspect modes.
970/// - `item_aspect_ratios` — width/height ratios for mixed-aspect items, bounded
971///   to 9:16–16:9. Missing or invalid ratios use square items.
972/// - `item_spacing` — spacing between items: 16dp for full-screen, 8dp for
973///   mixed-aspect uncontained, and zero otherwise.
974/// - `min_small_item_width`, `max_small_item_width` — small-item bounds, 40dp
975///   and 56dp by default.
976/// - `content_padding_start`, `content_padding_end` — padding at collection
977///   ends; mixed-aspect uncontained defaults to 16dp at the start and zero at
978///   the end. Ignored in full-screen mode.
979/// - `shape` — mask shape; defaults to rectangular full-screen pages and
980///   circular 28dp corners otherwise.
981/// - `controller` — optional external scroll controller.
982/// - `focus_requester` — optional handle for focusing viewport keyboard
983///   navigation.
984/// - `user_scroll_enabled` — enables gestures, keyboard, and accessibility
985///   navigation.
986/// - `reverse_layout` — reverses the layout and its navigation direction.
987/// - `fling_behavior` — single-advance snapping by default, or free scrolling
988///   for uncontained layouts. Full-screen pages always snap.
989/// - `accessibility_label` — localized collection name.
990/// - `content` — item slot, measured at its full width regardless of its mask.
991///
992/// ## Examples
993///
994/// ```
995/// use tessera_components::{
996///     carousel::{CarouselController, carousel},
997///     text::text,
998/// };
999/// use tessera_ui::{remember, tessera};
1000///
1001/// let mut controller = CarouselController::new(2);
1002/// controller.jump_to_item(4);
1003/// assert_eq!(controller.current_item(), 4);
1004///
1005/// #[tessera]
1006/// fn photos() {
1007///     let controller = remember(|| CarouselController::new(2));
1008///     assert_eq!(controller.with(CarouselController::current_item), 2);
1009///     carousel(12).controller(controller).content(|item| {
1010///         text(format!("Photo {}", item.index + 1));
1011///     });
1012/// }
1013/// ```
1014#[tessera]
1015pub fn carousel(
1016    item_count: usize,
1017    modifier: Option<Modifier>,
1018    height: Option<Dp>,
1019    variant: Option<CarouselVariant>,
1020    preferred_item_width: Option<Dp>,
1021    #[prop(into)] item_aspect_ratios: Option<Arc<[f32]>>,
1022    item_spacing: Option<Dp>,
1023    min_small_item_width: Option<Dp>,
1024    max_small_item_width: Option<Dp>,
1025    content_padding_start: Option<Dp>,
1026    content_padding_end: Option<Dp>,
1027    shape: Option<Shape>,
1028    controller: Option<State<CarouselController>>,
1029    focus_requester: Option<FocusRequester>,
1030    user_scroll_enabled: Option<bool>,
1031    reverse_layout: Option<bool>,
1032    fling_behavior: Option<CarouselFlingBehavior>,
1033    #[prop(into)] accessibility_label: Option<String>,
1034    content: Option<RenderSlotWith<CarouselItemInfo>>,
1035) {
1036    let variant = variant.unwrap_or_default();
1037    let vertical = variant == CarouselVariant::FullScreen;
1038    let controller = if let Some(controller) = controller {
1039        controller
1040    } else {
1041        remember(CarouselController::default)
1042    };
1043    let recognizer = remember(|| {
1044        DragRecognizer::new(DragSettings {
1045            axis: Some(if vertical {
1046                DragAxis::Vertical
1047            } else {
1048                DragAxis::Horizontal
1049            }),
1050            ..Default::default()
1051        })
1052    });
1053    let previous_vertical = remember(|| vertical);
1054    if previous_vertical.get() != vertical {
1055        previous_vertical.set(vertical);
1056        recognizer.set(DragRecognizer::new(DragSettings {
1057            axis: Some(if vertical {
1058                DragAxis::Vertical
1059            } else {
1060                DragAxis::Horizontal
1061            }),
1062            ..Default::default()
1063        }));
1064    }
1065    let focus = if let Some(focus) = focus_requester {
1066        focus
1067    } else {
1068        remember(FocusRequester::new).get()
1069    };
1070    if variant == CarouselVariant::UncontainedMultiAspectRatio {
1071        let ratios = item_aspect_ratios.as_deref().unwrap_or(&[]);
1072        let count = ratios.len().min(item_count);
1073        let changed = controller.with(|state| {
1074            state.aspect_ratios.len() != count
1075                || state
1076                    .aspect_ratios
1077                    .iter()
1078                    .zip(ratios)
1079                    .any(|(old, ratio)| *old != normalized_ratio(*ratio))
1080        });
1081        if changed {
1082            controller.with_mut(|state| {
1083                state.aspect_ratios = ratios
1084                    .iter()
1085                    .take(count)
1086                    .map(|ratio| normalized_ratio(*ratio))
1087                    .collect::<Vec<_>>()
1088                    .into();
1089                state.aspect_revision = state.aspect_revision.wrapping_add(1);
1090            });
1091        }
1092    }
1093    let mut behavior = fling_behavior.unwrap_or(
1094        if matches!(
1095            variant,
1096            CarouselVariant::Uncontained | CarouselVariant::UncontainedMultiAspectRatio
1097        ) {
1098            CarouselFlingBehavior::Free
1099        } else {
1100            CarouselFlingBehavior::SingleAdvance
1101        },
1102    );
1103    if vertical && behavior == CarouselFlingBehavior::Free {
1104        behavior = CarouselFlingBehavior::SingleAdvance;
1105    }
1106    if controller.with(|state| state.behavior != behavior) {
1107        controller.with_mut(|state| state.behavior = behavior);
1108    }
1109    let enabled = user_scroll_enabled.unwrap_or(true);
1110    let reverse = reverse_layout.unwrap_or(false);
1111    let navigation = focus::Navigation {
1112        viewport: focus,
1113        items: remember(std::collections::BTreeMap::new),
1114        pending: remember(|| None),
1115    };
1116    if !enabled
1117        || navigation
1118            .pending
1119            .get()
1120            .is_some_and(|index| index >= item_count)
1121    {
1122        if navigation.pending.get().is_some() {
1123            navigation.pending.set(None);
1124        }
1125    } else if navigation.pending.get().is_some() {
1126        FocusManager::current().defer_keyboard_until_layout();
1127        receive_frame_nanos(move |_| {
1128            if navigation.finish(controller.with(|state| !state.needs_frame())) {
1129                tessera_ui::FrameNanosControl::Stop
1130            } else {
1131                FocusManager::current().defer_keyboard_until_layout();
1132                tessera_ui::FrameNanosControl::Continue
1133            }
1134        });
1135    }
1136    if !enabled
1137        && controller.with(|state| {
1138            state.dragging || state.wheel_idle.is_some() || state.free_velocity != 0.0
1139        })
1140    {
1141        controller.with_mut(CarouselController::stop_input);
1142        recognizer.set(DragRecognizer::new(DragSettings {
1143            axis: Some(if vertical {
1144                DragAxis::Vertical
1145            } else {
1146                DragAxis::Horizontal
1147            }),
1148            ..Default::default()
1149        }));
1150    }
1151    if controller.with(CarouselController::needs_frame) {
1152        receive_frame_nanos(move |now| {
1153            controller.with_mut(|state| state.tick(now));
1154            if controller.with(CarouselController::needs_frame) {
1155                tessera_ui::FrameNanosControl::Continue
1156            } else {
1157                tessera_ui::FrameNanosControl::Stop
1158            }
1159        });
1160    }
1161    let density = scale_factor() as f32;
1162    let min_small = finite_dp(min_small_item_width.unwrap_or(Dp(40.0)), 40.0).max(1.0);
1163    let max_small = finite_dp(max_small_item_width.unwrap_or(Dp(56.0)), 56.0).max(min_small);
1164    let preferred = preferred_item_width
1165        .map(|width| finite_dp(width, 180.0).max(1.0))
1166        .or_else(|| {
1167            matches!(
1168                variant,
1169                CarouselVariant::MultiBrowse | CarouselVariant::Uncontained
1170            )
1171            .then_some(180.0 * density)
1172        });
1173    let height = Px(finite_dp(height.unwrap_or(Dp(180.0)), 180.0).round() as i32);
1174    let variable = variant == CarouselVariant::UncontainedMultiAspectRatio;
1175    let config = StrategyConfig {
1176        variant,
1177        viewport: 0.0,
1178        preferred,
1179        spacing: finite_dp(
1180            item_spacing.unwrap_or(Dp(if variant == CarouselVariant::FullScreen {
1181                16.0
1182            } else if variable {
1183                8.0
1184            } else {
1185                0.0
1186            })),
1187            0.0,
1188        ),
1189        density,
1190        count: item_count,
1191        item_height: if variable {
1192            controller.with(|state| {
1193                state
1194                    .config
1195                    .filter(|config| config.variant == variant)
1196                    .map(|config| config.item_height)
1197                    .unwrap_or(height.to_f32())
1198            })
1199        } else {
1200            0.0
1201        },
1202        aspect_revision: if variable {
1203            controller.with(|state| state.aspect_revision)
1204        } else {
1205            0
1206        },
1207        small_range: [min_small, max_small],
1208        padding: [
1209            finite_dp(
1210                content_padding_start.unwrap_or(Dp(if variable { 16.0 } else { 0.0 })),
1211                0.0,
1212            ),
1213            finite_dp(content_padding_end.unwrap_or(Dp(0.0)), 0.0),
1214        ],
1215    };
1216    if let Some(viewport) = controller.with(|state| {
1217        state
1218            .config
1219            .map(|config| config.viewport)
1220            .filter(|viewport| *viewport > 0.0)
1221    }) {
1222        let next = StrategyConfig {
1223            viewport,
1224            padding: config.padding.map(|padding| padding.min(viewport)),
1225            ..config
1226        };
1227        if controller.with(|state| state.config != Some(next)) {
1228            controller.with_mut(|state| state.configure(next));
1229        }
1230    }
1231    let (indices, offset) = controller.with(|state| {
1232        (
1233            item_indices(state, item_count, navigation.retained_item()),
1234            state.motion.value(),
1235        )
1236    });
1237    let policy = CarouselLayout {
1238        controller,
1239        config,
1240        height,
1241        indices,
1242        offset,
1243        reverse,
1244    };
1245    let scroll_range = (
1246        offset as f64,
1247        0.0,
1248        controller.with(|state| {
1249            state
1250                .strategy
1251                .as_ref()
1252                .map(|strategy| strategy.max_scroll(item_count))
1253                .unwrap_or(0.0)
1254        }) as f64,
1255    );
1256    let modifier = modifier
1257        .unwrap_or_else(|| Modifier::new().fill_max_width())
1258        .clip_to_bounds()
1259        .focusable()
1260        .focus_requester(focus)
1261        .focus_properties(
1262            FocusProperties::new()
1263                .can_focus(enabled && item_count > 1)
1264                .can_request_focus(enabled && item_count > 1)
1265                .skip_traversal(true),
1266        )
1267        .semantics(SemanticsArgs {
1268            role: Some(Role::List),
1269            merge_descendants: true,
1270            role_description: Some("carousel".into()),
1271            label: accessibility_label,
1272            collection_info: Some(if vertical {
1273                (item_count, 1, false)
1274            } else {
1275                (1, item_count, false)
1276            }),
1277            focusable: enabled && item_count > 1,
1278            scroll_x: (!vertical).then_some(scroll_range),
1279            scroll_y: vertical.then_some(scroll_range),
1280            ..Default::default()
1281        })
1282        .push_semantics(CarouselSemantics {
1283            controller,
1284            enabled,
1285            reverse,
1286            vertical,
1287        })
1288        .push_pointer_input(CarouselInput {
1289            controller,
1290            recognizer,
1291            focus,
1292            enabled,
1293            reverse,
1294            vertical,
1295            behavior,
1296            navigation,
1297        })
1298        .push_keyboard_input(CarouselInput {
1299            controller,
1300            recognizer,
1301            focus,
1302            enabled,
1303            reverse,
1304            vertical,
1305            behavior,
1306            navigation,
1307        });
1308    let corner = RoundedCorner::manual(Dp(28.0), 2.0);
1309    let shape = shape.unwrap_or(if variant == CarouselVariant::FullScreen {
1310        Shape::RECTANGLE
1311    } else {
1312        Shape::RoundedRectangle {
1313            top_left: corner,
1314            top_right: corner,
1315            bottom_right: corner,
1316            bottom_left: corner,
1317        }
1318    });
1319    let value = CarouselRenderState {
1320        count: item_count,
1321        shape,
1322        content,
1323    };
1324    let render_state = remember(|| value.clone());
1325    if render_state.with(|state| *state != value) {
1326        render_state.set(value);
1327    }
1328    layout().modifier(modifier).policy(policy).child(move || {
1329        let count = render_state.with(|state| state.count);
1330        let indices =
1331            controller.with(|state| item_indices(state, count, navigation.retained_item()));
1332        for index in indices {
1333            key(index, || {
1334                carousel_item(index, controller, render_state, navigation);
1335            });
1336        }
1337    });
1338}
1339
1340fn finite_dp(value: Dp, fallback: f32) -> f32 {
1341    let pixels = value.to_pixels_f32();
1342    if pixels.is_finite() {
1343        pixels.max(0.0)
1344    } else {
1345        fallback * scale_factor() as f32
1346    }
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351
1352    use tessera_ui::{
1353        Color, MOUSE_POINTER_ID, PressKeyEventType,
1354        headless::{HeadlessConfig, HeadlessRenderer},
1355    };
1356
1357    use crate::{
1358        boxed::boxed,
1359        button::button,
1360        column::column,
1361        flow_row::flow_row,
1362        image::{ImageData, image},
1363        lazy_list::lazy_column,
1364        scaffold::scaffold,
1365        spacer::spacer,
1366        text::text,
1367        theme::{MaterialTheme, material_theme},
1368    };
1369
1370    use super::*;
1371
1372    #[test]
1373    fn visible_item_width_intersects_the_viewport_at_both_ends() {
1374        for density in [1.0, 1.25, 1.5, 2.0] {
1375            for (center, width, viewport, expected) in [
1376                (176.0, 320.0, 380.0, 320.0),
1377                (394.625, 101.25, 380.0, 36.0),
1378                (83.0, 320.0, 380.0, 243.0),
1379                (-200.0, 320.0, 380.0, 0.0),
1380                (600.0, 320.0, 380.0, 0.0),
1381                (0.0, 320.0, 0.0, 0.0),
1382            ] {
1383                assert_eq!(
1384                    visible_mask_width(center * density, width * density, viewport * density),
1385                    expected * density
1386                );
1387            }
1388        }
1389    }
1390
1391    fn configuration(variant: CarouselVariant, count: usize) -> StrategyConfig {
1392        StrategyConfig {
1393            variant,
1394            viewport: 380.0,
1395            preferred: Some(186.0),
1396            spacing: 8.0,
1397            density: 1.0,
1398            count,
1399            small_range: [40.0, 56.0],
1400            padding: [0.0; 2],
1401            item_height: 180.0,
1402            aspect_revision: 0,
1403        }
1404    }
1405
1406    fn settle(state: &mut CarouselController) {
1407        let start = state.last_frame.unwrap_or(0);
1408        for frame in 1..=240 {
1409            state.tick(start + frame * 16_666_667);
1410        }
1411        assert!(
1412            !state.needs_frame(),
1413            "a settled carousel must stop requesting frames"
1414        );
1415    }
1416
1417    #[test]
1418    fn prelayout_navigation_clamps_and_resizes_without_losing_the_item() {
1419        let mut state = CarouselController::new(2);
1420        state.jump_to_item(7);
1421        state.configure(configuration(CarouselVariant::MultiBrowse, 10));
1422        assert_eq!(state.current_item(), 7);
1423        assert_eq!(
1424            state.motion.value(),
1425            state.strategy.as_ref().expect("strategy").snap(7, 10)
1426        );
1427        state.configure(StrategyConfig {
1428            viewport: 0.0,
1429            ..configuration(CarouselVariant::MultiBrowse, 10)
1430        });
1431        assert_eq!(state.current_item(), 7);
1432        assert!(state.masks.is_empty());
1433        state.configure(configuration(CarouselVariant::MultiBrowse, 10));
1434        assert_eq!(state.current_item(), 7);
1435        state.configure(StrategyConfig {
1436            viewport: 620.0,
1437            ..configuration(CarouselVariant::MultiBrowse, 10)
1438        });
1439        assert_eq!(state.current_item(), 7);
1440        state.configure(configuration(CarouselVariant::CenteredHero, 3));
1441        assert_eq!(state.current_item(), 2);
1442        state.configure(configuration(CarouselVariant::CenteredHero, 0));
1443        assert!(state.masks.is_empty());
1444        assert_eq!(state.current_item(), 0);
1445        assert!(!state.is_scrolling());
1446        state.configure(configuration(CarouselVariant::MultiBrowse, 10));
1447        assert!(!state.masks.is_empty());
1448    }
1449
1450    #[test]
1451    fn resizing_items_keep_content_centered_in_their_mask_instead_of_scrolling_rigidly() {
1452        let mut state = CarouselController::new(0);
1453        state.configure(configuration(CarouselVariant::MultiBrowse, 10));
1454        let before = state.masks.clone();
1455        let delta = 24.0;
1456        assert!(state.move_by(delta));
1457        let mut resized = 0;
1458        for &(index, center, width) in &before {
1459            let Some(&(_, next_center, next_width)) =
1460                state.masks.iter().find(|mask| mask.0 == index)
1461            else {
1462                continue;
1463            };
1464            if (next_width - width).abs() < 0.5 {
1465                continue;
1466            }
1467            resized += 1;
1468            let shift = center - next_center;
1469            assert!(
1470                shift > 0.0 && (shift - delta).abs() > 0.5,
1471                "item {index} content must move with its mask center, not rigidly with the scroll: \
1472                 shift {shift}, scroll {delta}, before {before:?}, after {:?}",
1473                state.masks
1474            );
1475        }
1476        assert!(
1477            resized > 0,
1478            "scrolling must resize at least one visible item: {before:?} -> {:?}",
1479            state.masks
1480        );
1481    }
1482
1483    #[test]
1484    fn navigation_realigns_the_current_item_after_free_scrolling() {
1485        for variant in [
1486            CarouselVariant::Uncontained,
1487            CarouselVariant::UncontainedMultiAspectRatio,
1488        ] {
1489            let mut state = CarouselController::default();
1490            state.configure(configuration(variant, 10));
1491            state.behavior = CarouselFlingBehavior::Free;
1492            assert!(!state.navigation_required(0));
1493            state.move_by(40.0);
1494            assert_eq!(state.current_item(), 0);
1495            assert!(state.navigation_required(0));
1496            state.request_at(0, true, 0);
1497            settle(&mut state);
1498            assert_eq!(state.motion.value(), 0.0);
1499            assert!(!state.navigation_required(0));
1500            state.dragging = true;
1501            assert!(state.navigation_required(0));
1502        }
1503        let mut state = CarouselController::default();
1504        state.configure(configuration(CarouselVariant::FullScreen, 3));
1505        state.jump_to_item(2);
1506        let end = state.motion.value();
1507        state.move_by(-40.0);
1508        assert_eq!(state.current_item(), 2);
1509        assert!(state.navigation_required(2));
1510        state.request_at(2, true, 0);
1511        settle(&mut state);
1512        assert_eq!(state.motion.value(), end);
1513        assert!(!state.navigation_required(2));
1514    }
1515
1516    #[test]
1517    fn retargeted_snaps_settle_inside_the_scroll_range() {
1518        let runtime = tessera_ui::RuntimeSession::new();
1519        let _scope = runtime.enter();
1520        let mut state = CarouselController::default();
1521        state.configure(configuration(CarouselVariant::MultiBrowse, 100));
1522        state.animate_to_item(90);
1523        state.tick(16_666_667);
1524        let partial = state.motion.value();
1525        assert!(partial > 0.0);
1526        state.animate_to_item(2);
1527        settle(&mut state);
1528        assert_eq!(state.current_item(), 2);
1529        assert_eq!(
1530            state.motion.value(),
1531            state.strategy.as_ref().expect("strategy").snap(2, 100)
1532        );
1533        state.motion = state.motion.with_velocity(-10_000.0);
1534        state.animate_to_item(0);
1535        settle(&mut state);
1536        assert_eq!(state.motion.value(), 0.0);
1537        state.jump_to_item(usize::MAX);
1538        assert_eq!(state.current_item(), 99);
1539        assert_eq!(
1540            state.motion.value(),
1541            state.strategy.as_ref().expect("strategy").max_scroll(100)
1542        );
1543    }
1544
1545    #[test]
1546    fn free_scrolling_keeps_fractional_offsets_and_snap_modes_limit_fling_distance() {
1547        let mut free = CarouselController::default();
1548        free.behavior = CarouselFlingBehavior::Free;
1549        free.configure(configuration(CarouselVariant::Uncontained, 100));
1550        free.move_by(53.25);
1551        free.drag_velocity = 400.0;
1552        free.last_input = Some(1);
1553        free.end_drag(CarouselFlingBehavior::Free, 1, false);
1554        settle(&mut free);
1555        let snap = free
1556            .strategy
1557            .as_ref()
1558            .expect("strategy")
1559            .snap(free.nearest(free.motion.value()), 100);
1560        assert!((free.motion.value() - snap).abs() > 1.0);
1561
1562        let mut single = CarouselController::default();
1563        single.configure(configuration(CarouselVariant::MultiBrowse, 100));
1564        single.move_by(30.0);
1565        single.drag_velocity = 4000.0;
1566        single.last_input = Some(1);
1567        single.end_drag(CarouselFlingBehavior::SingleAdvance, 1, false);
1568        assert_eq!(single.current_item(), 1);
1569        settle(&mut single);
1570        assert_eq!(single.current_item(), 1);
1571    }
1572
1573    #[test]
1574    fn wheel_idle_reads_the_latest_fling_setting() {
1575        let mut state = CarouselController::default();
1576        state.configure(configuration(CarouselVariant::MultiBrowse, 10));
1577        state.move_by(53.25);
1578        state.wheel_idle = Some(0);
1579        state.behavior = CarouselFlingBehavior::Free;
1580        settle(&mut state);
1581        assert_eq!(state.motion.value(), 53.25);
1582    }
1583
1584    #[derive(Clone, Copy)]
1585    struct Settings {
1586        variant: CarouselVariant,
1587        count: usize,
1588        enabled: bool,
1589        reverse: bool,
1590        dimmed: bool,
1591        stress: bool,
1592        ratios: [f32; 4],
1593        height: f64,
1594    }
1595
1596    #[derive(Clone, Default, PartialEq)]
1597    struct Probe {
1598        settings: tessera_ui::testing::TestProbe<Option<State<Settings>>>,
1599        controller: tessera_ui::testing::TestProbe<Option<State<CarouselController>>>,
1600        activated: tessera_ui::testing::TestProbe<Option<State<Vec<usize>>>>,
1601        raster: tessera_ui::testing::TestProbe<Option<State<ImageData>>>,
1602    }
1603
1604    #[tessera]
1605    fn fixture(probe: Probe) {
1606        let settings = remember(|| Settings {
1607            variant: CarouselVariant::MultiBrowse,
1608            count: 10,
1609            enabled: true,
1610            reverse: false,
1611            dimmed: false,
1612            stress: false,
1613            ratios: [16.0 / 9.0, 9.0 / 16.0, 1.0, 3.0 / 4.0],
1614            height: 180.0,
1615        });
1616        let controller = remember(CarouselController::default);
1617        let activated = remember(Vec::<usize>::new);
1618        let raster = remember(|| {
1619            let mut pixels = Vec::new();
1620            for _ in 0..8 {
1621                for x in 0..16 {
1622                    pixels.extend(if x == 3 || x == 4 {
1623                        [0, 255, 0, 255]
1624                    } else if x < 8 {
1625                        [255, 0, 0, 255]
1626                    } else {
1627                        [0, 0, 255, 255]
1628                    });
1629                }
1630            }
1631            ImageData::from_rgba(pixels, 16, 8)
1632        });
1633        probe.settings.set(Some(settings));
1634        probe.controller.set(Some(controller));
1635        probe.activated.set(Some(activated));
1636        probe.raster.set(Some(raster));
1637        material_theme().child(move || {
1638            boxed()
1639                .modifier(Modifier::new().fill_max_size().background(Color::WHITE))
1640                .children(move || {
1641                    if settings.with(|settings| settings.stress) {
1642                        for index in 0..40usize {
1643                            key(index, || {
1644                                image().painter(raster.get()).modifier(
1645                                    Modifier::new().size(Dp(24.0), Dp(24.0)).offset(
1646                                        Dp((10 + (index % 8) * 28) as f64),
1647                                        Dp((20 + (index / 8) * 28) as f64),
1648                                    ),
1649                                );
1650                            });
1651                        }
1652                    } else {
1653                        lazy_column()
1654                            .modifier(Modifier::new().fill_max_size())
1655                            .estimated_item_size(Dp(280.0))
1656                            .item(move || {
1657                                boxed()
1658                                    .modifier(Modifier::new().size(Dp(400.0), Dp(200.0)))
1659                                    .children(move || {
1660                                        let value = settings.get();
1661                                        carousel(value.count)
1662                                            .modifier(
1663                                                Modifier::new()
1664                                                    .width(Dp(380.0))
1665                                                    .offset(Dp(10.0), Dp(20.0)),
1666                                            )
1667                                            .height(Dp(value.height))
1668                                            .item_aspect_ratios(Vec::from(value.ratios))
1669                                            .preferred_item_width(Dp(186.0))
1670                                            .item_spacing(Dp(8.0))
1671                                            .variant(value.variant)
1672                                            .controller(controller)
1673                                            .user_scroll_enabled(value.enabled)
1674                                            .reverse_layout(value.reverse)
1675                                            .accessibility_label("Gallery")
1676                                            .content(move |info| {
1677                                                button()
1678                                                    .modifier(
1679                                                        Modifier::new().fill_max_size().alpha(
1680                                                            if settings
1681                                                                .with(|settings| settings.dimmed)
1682                                                            {
1683                                                                0.5
1684                                                            } else {
1685                                                                1.0
1686                                                            },
1687                                                        ),
1688                                                    )
1689                                                    .padding(Dp(0.0))
1690                                                    .color(Color::TRANSPARENT)
1691                                                    .shape(Shape::RECTANGLE)
1692                                                    .accessibility_label(format!(
1693                                                        "Item {}",
1694                                                        info.index
1695                                                    ))
1696                                                    .on_click(move || {
1697                                                        activated.with_mut(|items| {
1698                                                            items.push(info.index)
1699                                                        })
1700                                                    })
1701                                                    .child(move || {
1702                                                        image().painter(raster.get()).modifier(
1703                                                            Modifier::new().fill_max_size(),
1704                                                        );
1705                                                    });
1706                                            });
1707                                    });
1708                            })
1709                            .item(|| {
1710                                text("Following carousel");
1711                            });
1712                    }
1713                });
1714        });
1715    }
1716
1717    fn click(renderer: &mut HeadlessRenderer, point: [f32; 2]) {
1718        let point = PxPosition::from_f32_xy(point[0], point[1]);
1719        renderer.pointer_move(MOUSE_POINTER_ID, point);
1720        renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1721        renderer.step_frame();
1722        renderer.pointer_release(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1723        renderer.render_frames(3);
1724    }
1725
1726    fn check_native_carousel(samples: u32) {
1727        let probe = Probe::default();
1728        let output = probe.clone();
1729        let mut renderer = HeadlessRenderer::new(
1730            Box::new(move || {
1731                fixture(output.clone());
1732            }),
1733            vec![Box::new(crate::TesseraComponents)],
1734            HeadlessConfig {
1735                width: 500,
1736                height: 300,
1737                sample_count: samples,
1738                ..Default::default()
1739            },
1740        )
1741        .expect("carousel renderer");
1742        renderer.render_frames(8);
1743        let controller = probe.controller.get().expect("controller");
1744        let settings = probe.settings.get().expect("settings");
1745        let activated = probe.activated.get().expect("activations");
1746        let initial = renderer.read_pixels().expect("pixels");
1747        if let Some(directory) = std::env::var_os("TESSERA_CAROUSEL_QA_DIR") {
1748            let directory = std::path::PathBuf::from(directory);
1749            std::fs::create_dir_all(&directory).expect("carousel QA directory");
1750            tessera_ui::headless::write_png_file(
1751                directory.join(format!("carousel-content-msaa{samples}.png")),
1752                500,
1753                300,
1754                &initial,
1755            )
1756            .expect("carousel QA image");
1757            std::fs::write(
1758                directory.join(format!("carousel-snapshot-msaa{samples}.json")),
1759                renderer.snapshot().to_string(),
1760            )
1761            .expect("snapshot file");
1762        }
1763        let pixel: fn(&[u8], usize, usize) -> &[u8] =
1764            |pixels, x, y| &pixels[(y * 500 + x) * 4..(y * 500 + x) * 4 + 4];
1765        for center in [103usize, 265, 362] {
1766            assert_eq!(
1767                pixel(&initial, center - 10, 100),
1768                [255, 0, 0, 255],
1769                "full-size content must remain centered inside every mask at {center}; configuration: {:?}; masks: {:?}",
1770                controller.with(|state| state.config),
1771                controller.with(|state| state.masks.clone())
1772            );
1773            assert_eq!(pixel(&initial, center + 10, 100), [0, 0, 255, 255]);
1774        }
1775        assert_eq!(pixel(&initial, 56, 100), [0, 255, 0, 255]);
1776        assert_eq!(
1777            pixel(&initial, 218, 100),
1778            [0, 255, 0, 255],
1779            "masked images must preserve the full-width landmark position"
1780        );
1781        settings.with_mut(|settings| settings.dimmed = true);
1782        renderer.render_frames(3);
1783        let half = renderer.read_pixels().expect("half opacity");
1784        let color = pixel(&half, 93, 100);
1785        assert_eq!(color[0], 255);
1786        assert!(
1787            (i16::from(color[1]) - 188).abs() <= 1 && (i16::from(color[2]) - 188).abs() <= 1,
1788            "image opacity must be applied exactly once: {color:?}"
1789        );
1790        let raster = probe.raster.get().expect("raster state");
1791        let original = raster.get();
1792        settings.with_mut(|settings| settings.dimmed = false);
1793        raster.set(ImageData::from_rgba(Arc::clone(&original.data), 8, 16));
1794        renderer.render_frames(3);
1795        let reshaped = renderer.read_pixels().expect("reshaped raster pixels");
1796        assert_eq!(
1797            pixel(&reshaped, 93, 100),
1798            pixel(&reshaped, 113, 100),
1799            "reshaping shared pixels must use a texture with the new row dimensions"
1800        );
1801        raster.set(ImageData::from_rgba(vec![255, 0, 0, 128], 1, 1));
1802        renderer.render_frames(3);
1803        let translucent = renderer.read_pixels().expect("translucent image");
1804        let color = pixel(&translucent, 93, 100);
1805        assert_eq!(color[0], 255);
1806        assert!(
1807            (i16::from(color[1]) - 187).abs() <= 1 && (i16::from(color[2]) - 187).abs() <= 1,
1808            "transparent images must blend in linear light: {color:?}"
1809        );
1810        raster.set(original);
1811        renderer.render_frames(3);
1812        let tree = renderer
1813            .accessibility_snapshot()
1814            .expect("accessibility tree");
1815        let gallery = tree
1816            .nodes
1817            .iter()
1818            .find(|(_, node)| node.label() == Some("Gallery"))
1819            .expect("gallery");
1820        assert!(gallery.1.supports_action(Action::ScrollRight));
1821        let medium = tree
1822            .nodes
1823            .iter()
1824            .find(|(_, node)| node.label() == Some("Item 1"))
1825            .unwrap_or_else(|| {
1826                panic!(
1827                    "medium item missing; published nodes: {:?}",
1828                    tree.nodes
1829                        .iter()
1830                        .map(|(_, node)| (node.role(), node.label()))
1831                        .collect::<Vec<_>>()
1832                )
1833            });
1834        assert_eq!(
1835            medium.1.bounds(),
1836            Some(tessera_ui::accesskit::Rect {
1837                x0: 204.0,
1838                y0: 20.0,
1839                x1: 326.0,
1840                y1: 200.0
1841            })
1842        );
1843        click(&mut renderer, [180.0, 100.0]);
1844        assert_eq!(
1845            activated.get(),
1846            [0],
1847            "overlapping unmasked bounds must not steal the preceding item's click"
1848        );
1849        renderer.pointer_move(MOUSE_POINTER_ID, PxPosition::from_f32_xy(490.0, 290.0));
1850        renderer.render_frames(100);
1851        let focused = renderer.accessibility_snapshot().expect("focused tree");
1852        assert!(
1853            focused
1854                .nodes
1855                .iter()
1856                .any(|(id, node)| *id == focused.focus && node.label() == Some("Item 0"))
1857        );
1858        assert!(
1859            renderer.read_pixels().expect("focused image pixels") != initial,
1860            "focus feedback must remain visible over opaque carousel images"
1861        );
1862        tessera_ui::FocusManager::current().clear_focus(true);
1863        renderer.render_frames(12);
1864        assert!(renderer.read_pixels().expect("restored pixels") == initial);
1865
1866        let mut node = AccessibilityNode::new();
1867        let mut handler = None;
1868        CarouselSemantics {
1869            controller,
1870            enabled: true,
1871            reverse: false,
1872            vertical: false,
1873        }
1874        .apply(&mut node, &mut handler);
1875        handler.expect("scroll action")(Action::ScrollRight);
1876        renderer.render_frames(120);
1877        assert_eq!(controller.with(CarouselController::current_item), 1);
1878        assert!(!controller.with(CarouselController::is_scrolling));
1879        controller.with_mut(|state| state.jump_to_item(0));
1880        renderer.render_frames(3);
1881        let point = PxPosition::from_f32_xy(150.0, 100.0);
1882        renderer.pointer_move(MOUSE_POINTER_ID, point);
1883        renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
1884        renderer.step_frame();
1885        for x in [150.0, 120.0, 80.0, 40.0] {
1886            renderer.pointer_move(MOUSE_POINTER_ID, PxPosition::from_f32_xy(x, 100.0));
1887            renderer.step_frame();
1888        }
1889        renderer.pointer_release(
1890            MOUSE_POINTER_ID,
1891            PxPosition::from_f32_xy(40.0, 100.0),
1892            PressKeyEventType::Left,
1893        );
1894        renderer.render_frames(160);
1895        assert_eq!(
1896            activated.get(),
1897            [0],
1898            "dragging media must cancel the original tap"
1899        );
1900        assert!(controller.with(CarouselController::current_item) > 0);
1901        assert!(!controller.with(CarouselController::is_scrolling));
1902        let focused = renderer.accessibility_snapshot().expect("drag focus");
1903        let (gallery, _) = focused
1904            .nodes
1905            .iter()
1906            .find(|(_, node)| node.label() == Some("Gallery"))
1907            .expect("gallery focus target");
1908        assert_eq!(
1909            focused.focus, *gallery,
1910            "dragging focuses viewport keyboard navigation"
1911        );
1912
1913        settings.with_mut(|settings| settings.variant = CarouselVariant::Uncontained);
1914        controller.with_mut(|state| state.jump_to_item(2));
1915        renderer.render_frames(5);
1916        let before = controller.with(|state| state.motion.value());
1917        renderer.pointer_scroll(
1918            MOUSE_POINTER_ID,
1919            PxPosition::from_f32_xy(200.0, 100.0),
1920            -37.0,
1921            0.0,
1922        );
1923        renderer.render_frames(100);
1924        let free = controller.with(|state| state.motion.value());
1925        assert!((free - before).abs() > 1.0);
1926        assert!(!controller.with(CarouselController::is_scrolling));
1927        let nearest = controller.with(|state| {
1928            state
1929                .strategy
1930                .as_ref()
1931                .expect("strategy")
1932                .snap(state.nearest(free), 10)
1933        });
1934        assert!(
1935            (free - nearest).abs() > 1.0,
1936            "uncontained wheel scrolling must not snap"
1937        );
1938        renderer.pointer_scroll(
1939            MOUSE_POINTER_ID,
1940            PxPosition::from_f32_xy(200.0, 100.0),
1941            0.0,
1942            -30.0,
1943        );
1944        renderer.render_frames(3);
1945        assert_eq!(
1946            controller.with(|state| state.motion.value()),
1947            free,
1948            "vertical wheel movement belongs to the parent scroll container"
1949        );
1950
1951        for variant in [
1952            CarouselVariant::UncontainedMultiAspectRatio,
1953            CarouselVariant::Hero,
1954            CarouselVariant::CenteredHero,
1955            CarouselVariant::FullScreen,
1956            CarouselVariant::MultiBrowse,
1957        ] {
1958            settings.with_mut(|settings| settings.variant = variant);
1959            controller.with_mut(|state| state.jump_to_item(9));
1960            renderer.render_frames(8);
1961            let pixels = renderer.read_pixels().expect("variant pixels");
1962            assert!(pixels != initial);
1963            assert_eq!(controller.with(CarouselController::current_item), 9);
1964            if variant == CarouselVariant::UncontainedMultiAspectRatio {
1965                controller.with_mut(|state| state.jump_to_item(0));
1966                renderer.pointer_move(MOUSE_POINTER_ID, PxPosition::from_f32_xy(490.0, 290.0));
1967                renderer.render_frames(120);
1968                assert_eq!(controller.with(|state| state.masks[0]), (0, 176.0, 320.0));
1969                assert_eq!(controller.with(|state| state.masks[1].2), 101.25);
1970                let first = CarouselItemInfo {
1971                    index: 0,
1972                    controller: Some(controller),
1973                };
1974                let second = CarouselItemInfo {
1975                    index: 1,
1976                    controller: Some(controller),
1977                };
1978                assert_eq!(first.masked_width(), Dp(320.0));
1979                assert_eq!(second.full_width(), Dp(101.25));
1980                assert_eq!(second.masked_width(), Dp(36.0));
1981                controller.with_mut(|state| state.move_by(93.0));
1982                renderer.render_frames(8);
1983                assert_eq!(first.full_width(), Dp(320.0));
1984                assert_eq!(first.masked_width(), Dp(243.0));
1985                controller.with_mut(|state| state.jump_to_item(0));
1986                renderer.render_frames(8);
1987                let before = renderer.read_pixels().expect("mixed aspect pixels");
1988                assert_eq!(pixel(&before, 240, 110), [0, 0, 255, 255]);
1989                settings.with_mut(|settings| {
1990                    settings.ratios = [1.0, 16.0 / 9.0, 9.0 / 16.0, 3.0 / 4.0]
1991                });
1992                renderer.render_frames(8);
1993                assert_eq!(controller.with(|state| state.masks[0].2), 180.0);
1994                assert_eq!(controller.with(|state| state.masks[1].2), 320.0);
1995                let changed = renderer.read_pixels().expect("changed aspect pixels");
1996                assert_eq!(pixel(&changed, 240, 110), [255, 0, 0, 255]);
1997                settings.with_mut(|settings| settings.height = 120.0);
1998                renderer.render_frames(8);
1999                assert_eq!(controller.with(|state| state.masks[0].2), 120.0);
2000                let tree = renderer
2001                    .accessibility_snapshot()
2002                    .expect("mixed aspect accessibility");
2003                let bounds = tree
2004                    .nodes
2005                    .iter()
2006                    .find(|(_, node)| node.label() == Some("Item 0"))
2007                    .expect("first item")
2008                    .1
2009                    .bounds()
2010                    .expect("first item bounds");
2011                assert_eq!(bounds.width(), 120.0);
2012                assert_eq!(bounds.height(), 120.0);
2013                let settled = renderer.read_pixels().expect("settled mixed aspect pixels");
2014                renderer.render_frames(20);
2015                assert_eq!(controller.with(|state| state.masks[0].2), 120.0);
2016                assert_eq!(
2017                    renderer.read_pixels().expect("stable mixed aspect pixels"),
2018                    settled
2019                );
2020                settings.with_mut(|settings| {
2021                    settings.height = 180.0;
2022                    settings.ratios = [16.0 / 9.0, 9.0 / 16.0, 1.0, 3.0 / 4.0];
2023                });
2024            }
2025            if variant == CarouselVariant::FullScreen {
2026                assert_eq!(
2027                    controller.with(|state| state.masks.clone()),
2028                    vec![(9, 90.0, 180.0)]
2029                );
2030                controller.with_mut(|state| state.jump_to_item(3));
2031                renderer.render_frames(3);
2032                let mut node = AccessibilityNode::new();
2033                let mut handler = None;
2034                CarouselSemantics {
2035                    controller,
2036                    enabled: true,
2037                    reverse: false,
2038                    vertical: true,
2039                }
2040                .apply(&mut node, &mut handler);
2041                assert!(node.actions.contains(&Action::ScrollUp));
2042                assert!(node.actions.contains(&Action::ScrollDown));
2043                assert!(!node.actions.contains(&Action::ScrollRight));
2044                renderer.pointer_scroll(
2045                    MOUSE_POINTER_ID,
2046                    PxPosition::from_f32_xy(200.0, 100.0),
2047                    0.0,
2048                    -140.0,
2049                );
2050                renderer.render_frames(120);
2051                assert_eq!(controller.with(CarouselController::current_item), 4);
2052                let offset = controller.with(|state| state.motion.value());
2053                renderer.pointer_scroll(
2054                    MOUSE_POINTER_ID,
2055                    PxPosition::from_f32_xy(200.0, 100.0),
2056                    -140.0,
2057                    0.0,
2058                );
2059                renderer.render_frames(3);
2060                assert_eq!(controller.with(|state| state.motion.value()), offset);
2061                handler.expect("vertical scroll action")(Action::ScrollUp);
2062                renderer.render_frames(120);
2063                assert_eq!(controller.with(CarouselController::current_item), 3);
2064                settings.with_mut(|settings| settings.reverse = true);
2065                renderer.render_frames(3);
2066                renderer.pointer_scroll(
2067                    MOUSE_POINTER_ID,
2068                    PxPosition::from_f32_xy(200.0, 100.0),
2069                    0.0,
2070                    140.0,
2071                );
2072                renderer.render_frames(120);
2073                assert_eq!(controller.with(CarouselController::current_item), 4);
2074                settings.with_mut(|settings| settings.reverse = false);
2075            }
2076        }
2077        settings.with_mut(|settings| {
2078            settings.count = 2;
2079            settings.reverse = true;
2080        });
2081        renderer.render_frames(8);
2082        assert_eq!(controller.with(CarouselController::current_item), 1);
2083        settings.with_mut(|settings| settings.enabled = false);
2084        renderer.render_frames(3);
2085        let before = controller.with(|state| state.motion.value());
2086        renderer.pointer_scroll(
2087            MOUSE_POINTER_ID,
2088            PxPosition::from_f32_xy(200.0, 100.0),
2089            -50.0,
2090            0.0,
2091        );
2092        renderer.render_frames(8);
2093        assert_eq!(controller.with(|state| state.motion.value()), before);
2094        let tree = renderer.accessibility_snapshot().expect("disabled tree");
2095        let gallery = tree
2096            .nodes
2097            .iter()
2098            .find(|(_, node)| node.label() == Some("Gallery"))
2099            .expect("gallery");
2100        assert!(!gallery.1.supports_action(Action::ScrollLeft));
2101        assert!(!gallery.1.supports_action(Action::ScrollRight));
2102        settings.with_mut(|settings| settings.count = 0);
2103        renderer.render_frames(8);
2104        assert!(controller.with(|state| state.masks.is_empty()));
2105        let empty = renderer.read_pixels().expect("empty pixels");
2106        for y in 0..300 {
2107            for x in 0..500 {
2108                let expected = if (10..390).contains(&x) && (20..200).contains(&y) {
2109                    &[255, 255, 255, 255]
2110                } else {
2111                    pixel(&initial, x, y)
2112                };
2113                assert_eq!(pixel(&empty, x, y), expected, "empty carousel at {x}, {y}");
2114            }
2115        }
2116        settings.with_mut(|settings| {
2117            settings.count = 10;
2118            settings.enabled = true;
2119            settings.reverse = false;
2120            settings.variant = CarouselVariant::MultiBrowse;
2121        });
2122        renderer.render_frames(8);
2123        assert!(renderer.read_pixels().expect("regrown pixels") == initial);
2124        renderer.resize(640, 360);
2125        renderer.render_frames(8);
2126        let resized = renderer.read_pixels().expect("resized pixels");
2127        for center in [103usize, 265, 362] {
2128            let offset = (100 * 640 + center - 10) * 4;
2129            assert_eq!(&resized[offset..offset + 4], [255, 0, 0, 255]);
2130        }
2131        renderer.resize(500, 300);
2132        renderer.render_frames(8);
2133        assert!(renderer.read_pixels().expect("restored viewport pixels") == initial);
2134        controller.with_mut(|state| state.jump_to_item(3));
2135        renderer.render_frames(8);
2136        tessera_ui::dp::set_scale_factor(1.5);
2137        renderer.resize(750, 450);
2138        renderer.pointer_move(MOUSE_POINTER_ID, PxPosition::from_f32_xy(740.0, 440.0));
2139        renderer.render_frames(8);
2140        assert_eq!(renderer.snapshot()["scale_factor"], 1.5);
2141        assert!(probe.controller.get() == Some(controller));
2142        assert_eq!(controller.with(CarouselController::current_item), 3);
2143        let scaled = renderer.read_pixels().expect("density change pixels");
2144        for (_, center, width) in controller.with(|state| state.masks.clone()) {
2145            if width < 60.0 || center < 15.0 || center > 720.0 {
2146                continue;
2147            }
2148            let x = center.round() as usize;
2149            let offset = (150 * 750 + x) * 4;
2150            assert_eq!(&scaled[offset..offset + 4], [255, 0, 0, 255]);
2151        }
2152        tessera_ui::dp::set_scale_factor(1.0);
2153        renderer.resize(390, 780);
2154        renderer.render_frames(120);
2155        assert_eq!(controller.with(CarouselController::current_item), 3);
2156        let tree = renderer
2157            .accessibility_snapshot()
2158            .expect("portrait accessibility");
2159        let following = tree
2160            .nodes
2161            .iter()
2162            .find(|(_, node)| node.label() == Some("Following carousel"))
2163            .expect("following item must remain visible");
2164        let bounds = following.1.bounds().expect("following bounds");
2165        assert!(bounds.y0 >= 200.0 && bounds.y1 < 780.0);
2166        let portrait = renderer.read_pixels().expect("portrait pixels");
2167        assert!((205..235).any(|y| {
2168            (5..180).any(|x| portrait[(y * 390 + x) * 4..(y * 390 + x) * 4 + 3] != [255, 255, 255])
2169        }));
2170        renderer.resize(500, 300);
2171        renderer.render_frames(8);
2172        assert_eq!(controller.with(CarouselController::current_item), 3);
2173        controller.with_mut(|state| state.jump_to_item(0));
2174        renderer.render_frames(8);
2175        assert!(renderer.read_pixels().expect("restored density pixels") == initial);
2176        let uploads = renderer
2177            .graphics_work()
2178            .group("image_textures")
2179            .texture_uploads;
2180        let original = raster.get();
2181        raster.set(ImageData::from_rgba(
2182            original.data.as_ref().clone(),
2183            original.width,
2184            original.height,
2185        ));
2186        settings.with_mut(|settings| settings.stress = true);
2187        renderer.render_frames(3);
2188        assert_eq!(
2189            renderer
2190                .graphics_work()
2191                .group("image_textures")
2192                .texture_uploads,
2193            uploads,
2194            "independently decoded identical pixels reuse the existing texture"
2195        );
2196        let repeated = renderer.read_pixels().expect("repeated image instances");
2197        for index in 0..40usize {
2198            let x = 10 + (index % 8) * 28;
2199            let y = 20 + (index / 8) * 28;
2200            assert_eq!(
2201                pixel(&repeated, x + 9, y + 12),
2202                [255, 0, 0, 255],
2203                "instance {index} after arena growth"
2204            );
2205            assert_eq!(pixel(&repeated, x + 18, y + 12), [0, 0, 255, 255]);
2206        }
2207        let allocations = renderer
2208            .graphics_work()
2209            .group("image_instances")
2210            .buffer_allocations;
2211        let uploads = renderer
2212            .graphics_work()
2213            .group("image_textures")
2214            .texture_uploads;
2215        renderer.render_frames(5);
2216        assert_eq!(
2217            renderer
2218                .graphics_work()
2219                .group("image_instances")
2220                .buffer_allocations,
2221            allocations
2222        );
2223        assert_eq!(
2224            renderer
2225                .graphics_work()
2226                .group("image_textures")
2227                .texture_uploads,
2228            uploads
2229        );
2230        let retired_pixels = Arc::downgrade(&original.data);
2231        drop(original);
2232        settings.with_mut(|settings| {
2233            settings.stress = false;
2234            settings.count = 0;
2235        });
2236        renderer.render_frames(2);
2237        assert!(
2238            retired_pixels.upgrade().is_none(),
2239            "Unused image caches must release CPU pixels"
2240        );
2241        settings.with_mut(|settings| settings.stress = true);
2242        renderer.render_frames(3);
2243        assert_eq!(
2244            renderer
2245                .graphics_work()
2246                .group("image_textures")
2247                .texture_uploads,
2248            uploads,
2249            "restoring live pixels reuses their texture"
2250        );
2251        assert!(renderer.read_pixels().expect("restored cached image") == repeated);
2252        let allocations = renderer
2253            .graphics_work()
2254            .group("image_textures")
2255            .texture_allocations;
2256        for shade in 1..=5_u8 {
2257            let retired_pixels = Arc::downgrade(&raster.get().data);
2258            let mut pixels = vec![0; 2048 * 2048 * 4];
2259            for pixel in pixels.chunks_exact_mut(4) {
2260                pixel.copy_from_slice(&[shade * 31, 0, 0, 255]);
2261            }
2262            raster.set(ImageData::from_rgba(pixels, 2048, 2048));
2263            renderer.render_frames(2);
2264            if shade > 1 {
2265                assert!(
2266                    retired_pixels.upgrade().is_none(),
2267                    "Replaced images must release CPU pixels"
2268                );
2269            }
2270        }
2271        assert_eq!(
2272            renderer
2273                .graphics_work()
2274                .group("image_textures")
2275                .texture_allocations,
2276            allocations + 5
2277        );
2278    }
2279
2280    #[test]
2281    #[ignore = "Requires a native GPU adapter"]
2282    fn native_carousel_masks_input_and_dynamic_props_at_one_sample() {
2283        check_native_carousel(1);
2284    }
2285
2286    #[test]
2287    #[ignore = "Requires a native GPU adapter"]
2288    fn native_carousel_masks_input_and_dynamic_props_at_four_samples() {
2289        check_native_carousel(4);
2290    }
2291
2292    #[tessera]
2293    fn responsive_section(controller: State<CarouselController>) {
2294        column().modifier(Modifier::new().semantics(SemanticsArgs {
2295            label: Some("Responsive carousel section".into()), merge_descendants: true, ..Default::default()
2296        })).children(move || {
2297            text("Carousel");
2298            text("Items retain their full size as masks expand and collapse. Drag horizontally or use arrow keys, Home, and End.");
2299            flow_row().item_spacing(Dp(8.0)).line_spacing(Dp(8.0)).children(move || {
2300                for label in ["Layout: Multi-browse", "Previous item", "Next item", "Use reverse layout", "Disable scrolling"] {
2301                    button().tonal().child(move || { text(label); });
2302                }
2303            });
2304            spacer().modifier(Modifier::new().height(Dp(12.0)));
2305            carousel(12).controller(controller).height(Dp(180.0)).preferred_item_width(Dp(186.0)).item_spacing(Dp(8.0)).content(|item| {
2306                button().modifier(Modifier::new().fill_max_size()).child(move || { text(format!("{} {}", if item.masked_width() >= Dp(120.0) { "Collection" } else { "Preview" }, item.index + 1)); });
2307            });
2308            text(format!("Current item: {}", controller.with(CarouselController::current_item)));
2309            text("No item activated");
2310        });
2311    }
2312
2313    #[tessera]
2314    fn responsive_feed(probe: Probe) {
2315        let controller = remember(CarouselController::default);
2316        probe.controller.set(Some(controller));
2317        material_theme().child(move || {
2318            let theme = tessera_ui::use_context::<MaterialTheme>().expect("material theme");
2319            scaffold()
2320                .top_bar(|| {
2321                    spacer().modifier(Modifier::new().fill_max_width().height(Dp(64.0)));
2322                })
2323                .content(move || {
2324                    lazy_column()
2325                        .modifier(Modifier::new().fill_max_size())
2326                        .estimated_item_size(Dp(280.0))
2327                        .content_padding(Dp(16.0))
2328                        .item_spacing(Dp(24.0))
2329                        .item(move || {
2330                            column().children(move || {
2331                                text("Material Components")
2332                                    .style(theme.get().typography.headline_large);
2333                                spacer().modifier(Modifier::new().height(Dp(8.0)));
2334                                text("Additional Material 3 components beyond the basic set.");
2335                            });
2336                        })
2337                        .item(move || {
2338                            responsive_section(controller);
2339                        })
2340                        .item(|| {
2341                            text("Following responsive section");
2342                        });
2343                });
2344        });
2345    }
2346
2347    #[test]
2348    #[ignore = "Requires a native GPU adapter"]
2349    fn native_responsive_feed_keeps_following_items_visible_after_density_changes() {
2350        let probe = Probe::default();
2351        let output = probe.clone();
2352        let mut renderer = HeadlessRenderer::new(
2353            Box::new(move || {
2354                responsive_feed(output.clone());
2355            }),
2356            vec![Box::new(crate::TesseraComponents)],
2357            HeadlessConfig {
2358                width: 1316,
2359                height: 1307,
2360                scale_factor: 1.5,
2361                sample_count: 4,
2362                ..Default::default()
2363            },
2364        )
2365        .expect("responsive renderer");
2366        renderer.render_frames(20);
2367        let controller = probe.controller.get().expect("responsive controller");
2368        controller.with_mut(|state| state.animate_to_item(4));
2369        renderer.render_frames(120);
2370        tessera_ui::dp::set_scale_factor(1.0);
2371        renderer.resize(1, 780);
2372        renderer.render_frames(2);
2373        renderer.resize(390, 780);
2374        for frame in 0..120 {
2375            renderer.step_frame();
2376            assert_eq!(
2377                controller.with(CarouselController::current_item),
2378                4,
2379                "portrait frame {frame}"
2380            );
2381        }
2382        let tree = renderer.accessibility_snapshot().expect("responsive tree");
2383        let masks = controller.with(|state| state.masks.clone());
2384        let snapshot = renderer.snapshot().to_string();
2385        assert!(
2386            snapshot.contains("\"label\":\"Preview 6\""),
2387            "the next item's nested content must observe its smaller mask; masks: {masks:?}"
2388        );
2389        assert!(
2390            !snapshot.contains("\"label\":\"Collection 6\""),
2391            "a previously large item's label must update after resize"
2392        );
2393        let section = tree
2394            .nodes
2395            .iter()
2396            .find(|(_, node)| node.label() == Some("Responsive carousel section"))
2397            .expect("carousel section")
2398            .1
2399            .bounds()
2400            .expect("section bounds");
2401        let following = tree
2402            .nodes
2403            .iter()
2404            .find(|(_, node)| node.label() == Some("Following responsive section"))
2405            .expect("following section")
2406            .1
2407            .bounds()
2408            .expect("following bounds");
2409        assert!(section.y1 < 740.0, "carousel section: {section:?}");
2410        assert!(
2411            following.y0 >= section.y1 + 24.0 && following.y1 <= 780.0,
2412            "section: {section:?}; following: {following:?}"
2413        );
2414        let pixels = renderer.read_pixels().expect("responsive pixels");
2415        assert!(
2416            (following.y0.floor() as usize..following.y1.ceil() as usize).any(|y| {
2417                (following.x0.floor() as usize..following.x1.ceil().min(390.0) as usize).any(|x| {
2418                    pixels[(y * 390 + x) * 4..(y * 390 + x) * 4 + 3]
2419                        .iter()
2420                        .all(|value| *value < 180)
2421                })
2422            }),
2423            "following text must remain rendered after zero-width measurement"
2424        );
2425        tessera_ui::dp::set_scale_factor(1.5);
2426        renderer.resize(1316, 1307);
2427        renderer.render_frames(120);
2428        assert_eq!(controller.with(CarouselController::current_item), 4);
2429        tessera_ui::dp::set_scale_factor(1.0);
2430    }
2431}