1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum CarouselVariant {
41 #[default]
43 MultiBrowse,
44 Uncontained,
46 UncontainedMultiAspectRatio,
48 Hero,
50 CenteredHero,
52 FullScreen,
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum CarouselFlingBehavior {
59 SingleAdvance,
61 MultiAdvance,
63 Free,
65}
66
67#[derive(Clone, Copy, Default, PartialEq)]
72pub struct CarouselItemInfo {
73 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 pub fn full_width(&self) -> Dp {
90 self.widths().0
91 }
92
93 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#[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 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 pub fn current_item(&self) -> usize {
191 self.current_item
192 }
193
194 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 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 pub fn jump_to_item(&mut self, item: usize) {
209 self.request(item, false);
210 }
211
212 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#[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}