Skip to main content

tessera_components/
pager.rs

1//! Pager container for swipeable pages.
2//!
3//! ## Usage
4//!
5//! Show onboarding steps or media carousels that snap between pages.
6use tessera_foundation::gesture::{
7    DragAxis, DragRecognizer, DragSettings, ScrollRecognizer, ScrollSettings,
8};
9use tessera_ui::{
10    AxisConstraint, CallbackWith, ComputedData, Constraint, Dp, FocusProperties, KeyboardInput,
11    KeyboardInputModifierNode, LayoutResult, MeasurementError, Modifier, PointerInput,
12    PointerInputModifierNode, Px, PxPosition, ScrollDeltaUnit, ScrollEventSource, State, key,
13    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
14    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
15    normalize_platform_scroll_delta, receive_frame_nanos, remember, tessera, winit,
16};
17
18use crate::{
19    alignment::CrossAxisAlignment,
20    layout_flex::{cross_axis_child_constraint, px_mul},
21    modifier::ModifierExt as _,
22    pos_misc::is_position_inside_bounds,
23};
24
25const DEFAULT_SNAP_THRESHOLD: f32 = 0.5;
26const DEFAULT_SCROLL_SMOOTHING: f32 = 0.12;
27const SNAP_IDLE_TIME_NANOS: u64 = 120_000_000;
28
29/// Describes how a pager page is sized along the scroll axis.
30#[derive(Clone, Copy, Debug, PartialEq, Default)]
31pub enum PagerPageSize {
32    /// Page size fills the available main-axis space.
33    #[default]
34    Fill,
35    /// Page size is a fixed density-independent value.
36    Fixed(Dp),
37}
38
39/// Configuration arguments shared by pager variants.
40#[derive(Clone, PartialEq)]
41struct PagerConfig {
42    /// Modifier chain applied to the pager subtree.
43    pub modifier: Modifier,
44    /// Total number of pages available in the pager.
45    pub page_count: usize,
46    /// Initial page index when the pager is first created.
47    pub initial_page: usize,
48    /// Size of each page along the scroll axis.
49    pub page_size: PagerPageSize,
50    /// Spacing between pages.
51    pub page_spacing: Dp,
52    /// Symmetric padding applied before the first and after the last page.
53    pub content_padding: Dp,
54    /// Number of extra pages kept alive on either side of the visible pages.
55    pub beyond_viewport_page_count: usize,
56    /// Alignment for pages along the cross axis.
57    pub cross_axis_alignment: CrossAxisAlignment,
58    /// Whether user scrolling is enabled.
59    pub user_scroll_enabled: bool,
60    /// Fraction of a page that must be crossed to snap to the next page.
61    pub snap_threshold: f32,
62    /// Smoothing factor for snapping animations.
63    pub scroll_smoothing: f32,
64    /// Optional page-rendering callback.
65    pub page_content: CallbackWith<usize>,
66    /// Optional external pager controller.
67    ///
68    /// When this is `None`, the pager creates and owns an internal controller.
69    pub controller: Option<State<PagerController>>,
70}
71
72fn normalize_pager_scroll_delta(
73    delta_x: f32,
74    delta_y: f32,
75    unit: Option<ScrollDeltaUnit>,
76    source: Option<ScrollEventSource>,
77) -> (f32, f32) {
78    match (source, unit) {
79        (Some(source), Some(unit)) => {
80            normalize_platform_scroll_delta(delta_x, delta_y, unit, source)
81        }
82        _ => (delta_x, delta_y),
83    }
84}
85
86impl Default for PagerConfig {
87    fn default() -> Self {
88        Self {
89            modifier: Modifier::new().fill_max_size(),
90            page_count: 0,
91            initial_page: 0,
92            page_size: PagerPageSize::default(),
93            page_spacing: Dp(0.0),
94            content_padding: Dp(0.0),
95            beyond_viewport_page_count: 0,
96            cross_axis_alignment: CrossAxisAlignment::Center,
97            user_scroll_enabled: true,
98            snap_threshold: DEFAULT_SNAP_THRESHOLD,
99            scroll_smoothing: DEFAULT_SCROLL_SMOOTHING,
100            page_content: CallbackWith::default_value(),
101            controller: None,
102        }
103    }
104}
105
106struct PagerParams {
107    modifier: Option<Modifier>,
108    page_count: usize,
109    initial_page: usize,
110    page_size: PagerPageSize,
111    page_spacing: Dp,
112    content_padding: Dp,
113    beyond_viewport_page_count: usize,
114    cross_axis_alignment: CrossAxisAlignment,
115    user_scroll_enabled: bool,
116    snap_threshold: Option<f32>,
117    scroll_smoothing: Option<f32>,
118    page_content: Option<CallbackWith<usize>>,
119    controller: Option<State<PagerController>>,
120}
121
122fn pager_config_from_params(params: PagerParams) -> PagerConfig {
123    let defaults = PagerConfig::default();
124    PagerConfig {
125        modifier: params.modifier.unwrap_or(defaults.modifier),
126        page_count: params.page_count,
127        initial_page: params.initial_page,
128        page_size: params.page_size,
129        page_spacing: params.page_spacing,
130        content_padding: params.content_padding,
131        beyond_viewport_page_count: params.beyond_viewport_page_count,
132        cross_axis_alignment: params.cross_axis_alignment,
133        user_scroll_enabled: params.user_scroll_enabled,
134        snap_threshold: params.snap_threshold.unwrap_or(defaults.snap_threshold),
135        scroll_smoothing: params.scroll_smoothing.unwrap_or(defaults.scroll_smoothing),
136        page_content: params
137            .page_content
138            .unwrap_or_else(CallbackWith::default_value),
139        controller: params.controller,
140    }
141}
142
143/// Controller for pager components.
144#[derive(Clone, PartialEq)]
145pub struct PagerController {
146    current_page: usize,
147    current_page_offset_fraction: f32,
148    page_count: usize,
149    page_size: Px,
150    page_spacing: Px,
151    scroll_offset: f32,
152    target_offset: f32,
153    last_frame_nanos: Option<u64>,
154    last_scroll_frame_nanos: Option<u64>,
155    is_dragging: bool,
156    initialized: bool,
157}
158
159impl PagerController {
160    /// Creates a new controller with the requested initial page.
161    pub fn new(initial_page: usize) -> Self {
162        Self {
163            current_page: initial_page,
164            current_page_offset_fraction: 0.0,
165            page_count: 0,
166            page_size: Px::ZERO,
167            page_spacing: Px::ZERO,
168            scroll_offset: 0.0,
169            target_offset: 0.0,
170            last_frame_nanos: None,
171            last_scroll_frame_nanos: None,
172            is_dragging: false,
173            initialized: false,
174        }
175    }
176
177    /// Returns the currently selected page.
178    pub fn current_page(&self) -> usize {
179        self.current_page
180    }
181
182    /// Returns the current page offset fraction in the range -0.5..0.5.
183    pub fn current_page_offset_fraction(&self) -> f32 {
184        self.current_page_offset_fraction
185    }
186
187    /// Jumps immediately to the requested page.
188    pub fn jump_to_page(&mut self, page: usize) {
189        let page = self.clamp_page(page);
190        self.current_page = page;
191        let offset = self.offset_for_page(page);
192        self.scroll_offset = offset;
193        self.target_offset = offset;
194        self.last_scroll_frame_nanos = None;
195        self.update_current_page_from_offset();
196    }
197
198    /// Scrolls toward the requested page using snap smoothing.
199    pub fn scroll_to_page(&mut self, page: usize) {
200        let page = self.clamp_page(page);
201        self.target_offset = self.offset_for_page(page);
202        self.last_scroll_frame_nanos = None;
203    }
204
205    fn set_page_count(&mut self, page_count: usize) {
206        self.page_count = page_count;
207        if page_count == 0 {
208            self.current_page = 0;
209            self.scroll_offset = 0.0;
210            self.target_offset = 0.0;
211            self.current_page_offset_fraction = 0.0;
212            return;
213        }
214        if self.current_page >= page_count {
215            self.current_page = page_count - 1;
216        }
217    }
218
219    fn update_layout(&mut self, page_size: Px, page_spacing: Px, page_count: usize) {
220        let size_changed = page_size != self.page_size || page_spacing != self.page_spacing;
221        self.page_size = page_size;
222        self.page_spacing = page_spacing;
223        self.page_count = page_count;
224        self.current_page = self.clamp_page(self.current_page);
225
226        if page_count == 0 {
227            self.scroll_offset = 0.0;
228            self.target_offset = 0.0;
229            self.current_page_offset_fraction = 0.0;
230            return;
231        }
232
233        if !self.initialized && page_size > Px::ZERO {
234            let offset = self.offset_for_page(self.current_page);
235            self.scroll_offset = offset;
236            self.target_offset = offset;
237            self.initialized = true;
238        } else if size_changed && page_size > Px::ZERO {
239            let offset = self.offset_for_page(self.current_page);
240            self.scroll_offset = offset;
241            self.target_offset = offset;
242        }
243
244        self.scroll_offset = self.clamp_offset(self.scroll_offset);
245        self.target_offset = self.clamp_offset(self.target_offset);
246        self.update_current_page_from_offset();
247    }
248
249    fn tick(&mut self, frame_nanos: u64, snap_threshold: f32, scroll_smoothing: f32) {
250        if self.page_count == 0 {
251            return;
252        }
253        if self.page_distance() <= f32::EPSILON {
254            return;
255        }
256
257        let snap_threshold = snap_threshold.clamp(0.0, 1.0);
258        let scroll_smoothing = scroll_smoothing.clamp(0.0, 1.0);
259        let idle = self
260            .last_scroll_frame_nanos
261            .map(|last_scroll_frame_nanos| {
262                frame_nanos.saturating_sub(last_scroll_frame_nanos) > SNAP_IDLE_TIME_NANOS
263            })
264            .unwrap_or(true);
265
266        if idle && !self.is_dragging {
267            let target_page = self.snap_target_page(snap_threshold);
268            self.target_offset = self.offset_for_page(target_page);
269        }
270
271        self.update_scroll_offset(frame_nanos, scroll_smoothing);
272        self.scroll_offset = self.clamp_offset(self.scroll_offset);
273        self.update_current_page_from_offset();
274    }
275
276    fn has_pending_animation_frame(&self, frame_nanos: u64) -> bool {
277        if self.page_count == 0 || self.page_distance() <= f32::EPSILON {
278            return false;
279        }
280
281        if self.is_dragging {
282            return true;
283        }
284
285        if (self.target_offset - self.scroll_offset).abs() > f32::EPSILON {
286            return true;
287        }
288
289        self.last_scroll_frame_nanos
290            .map(|last_scroll_frame_nanos| {
291                frame_nanos.saturating_sub(last_scroll_frame_nanos) <= SNAP_IDLE_TIME_NANOS
292            })
293            .unwrap_or(false)
294    }
295
296    fn apply_scroll_delta(&mut self, delta: f32, frame_nanos: u64) {
297        if self.page_distance() <= f32::EPSILON || self.page_count == 0 {
298            return;
299        }
300        self.scroll_offset = self.clamp_offset(self.scroll_offset + delta);
301        self.target_offset = self.scroll_offset;
302        self.last_scroll_frame_nanos = Some(frame_nanos);
303        self.update_current_page_from_offset();
304    }
305
306    fn start_drag(&mut self, frame_nanos: u64) {
307        self.is_dragging = true;
308        self.last_scroll_frame_nanos = Some(frame_nanos);
309    }
310
311    fn end_drag(&mut self) {
312        self.is_dragging = false;
313    }
314
315    fn is_dragging(&self) -> bool {
316        self.is_dragging
317    }
318
319    fn scroll_offset_px(&self) -> Px {
320        Px::saturating_from_f32(self.scroll_offset)
321    }
322
323    fn clamp_page(&self, page: usize) -> usize {
324        if self.page_count == 0 {
325            0
326        } else {
327            page.min(self.page_count.saturating_sub(1))
328        }
329    }
330
331    fn page_distance(&self) -> f32 {
332        (self.page_size + self.page_spacing).to_f32()
333    }
334
335    fn offset_for_page(&self, page: usize) -> f32 {
336        -self.page_distance() * page as f32
337    }
338
339    fn clamp_offset(&self, offset: f32) -> f32 {
340        if self.page_count <= 1 || self.page_distance() <= f32::EPSILON {
341            return 0.0;
342        }
343        let max_offset = 0.0;
344        let min_offset = -self.page_distance() * self.page_count.saturating_sub(1) as f32;
345        offset.clamp(min_offset, max_offset)
346    }
347
348    fn snap_target_page(&self, threshold: f32) -> usize {
349        let distance = self.page_distance();
350        if distance <= f32::EPSILON || self.page_count == 0 {
351            return 0;
352        }
353        let page_float = -self.scroll_offset / distance;
354        let base_page = page_float.floor();
355        let fraction = page_float - base_page;
356        let target = if fraction >= threshold {
357            base_page + 1.0
358        } else {
359            base_page
360        };
361        let max_page = self.page_count.saturating_sub(1) as f32;
362        if target.is_finite() {
363            target.clamp(0.0, max_page) as usize
364        } else {
365            0
366        }
367    }
368
369    fn update_current_page_from_offset(&mut self) {
370        let distance = self.page_distance();
371        if distance <= f32::EPSILON || self.page_count == 0 {
372            self.current_page = 0;
373            self.current_page_offset_fraction = 0.0;
374            return;
375        }
376
377        let page_float = -self.scroll_offset / distance;
378        let mut nearest = page_float.round();
379        if !nearest.is_finite() {
380            nearest = 0.0;
381        }
382        let max_page = self.page_count.saturating_sub(1) as f32;
383        let current_page = nearest.clamp(0.0, max_page) as usize;
384        let snapped_offset = -distance * current_page as f32;
385        let offset_fraction = ((self.scroll_offset - snapped_offset) / distance).clamp(-0.5, 0.5);
386        self.current_page = current_page;
387        self.current_page_offset_fraction = offset_fraction;
388    }
389
390    fn update_scroll_offset(&mut self, frame_nanos: u64, smoothing: f32) {
391        let delta_time = if let Some(last_frame_nanos) = self.last_frame_nanos {
392            frame_nanos.saturating_sub(last_frame_nanos) as f32 / 1_000_000_000.0
393        } else {
394            1.0 / 60.0
395        };
396        self.last_frame_nanos = Some(frame_nanos);
397
398        let diff = self.target_offset - self.scroll_offset;
399        if diff.abs() < 0.5 {
400            self.scroll_offset = self.target_offset;
401            return;
402        }
403
404        let mut movement_factor = (1.0 - smoothing) * delta_time * 60.0;
405        if movement_factor > 1.0 {
406            movement_factor = 1.0;
407        }
408
409        self.scroll_offset += diff * movement_factor;
410    }
411}
412
413impl Default for PagerController {
414    fn default() -> Self {
415        Self::new(0)
416    }
417}
418
419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
420enum PagerAxis {
421    Horizontal,
422    Vertical,
423}
424
425impl PagerAxis {
426    fn cross(self, size: ComputedData) -> Px {
427        match self {
428            Self::Horizontal => size.height,
429            Self::Vertical => size.width,
430        }
431    }
432
433    fn pack_size(self, main: Px, cross: Px) -> ComputedData {
434        match self {
435            Self::Horizontal => ComputedData {
436                width: main,
437                height: cross,
438            },
439            Self::Vertical => ComputedData {
440                width: cross,
441                height: main,
442            },
443        }
444    }
445
446    fn position(self, main: Px, cross: Px) -> PxPosition {
447        match self {
448            Self::Horizontal => PxPosition::new(main, cross),
449            Self::Vertical => PxPosition::new(cross, main),
450        }
451    }
452
453    fn scroll_delta(self, delta_x: f32, delta_y: f32) -> f32 {
454        match self {
455            Self::Horizontal => {
456                if delta_x.abs() >= 0.01 {
457                    delta_x
458                } else {
459                    delta_y
460                }
461            }
462            Self::Vertical => {
463                if delta_y.abs() >= 0.01 {
464                    delta_y
465                } else {
466                    delta_x
467                }
468            }
469        }
470    }
471}
472
473#[derive(Clone)]
474struct PagerLayout {
475    axis: PagerAxis,
476    cross_axis_alignment: CrossAxisAlignment,
477    page_size: PagerPageSize,
478    page_spacing: Px,
479    content_padding: Px,
480    page_count: usize,
481    visible_pages: Vec<usize>,
482    scroll_offset: Px,
483    controller: State<PagerController>,
484}
485
486impl PartialEq for PagerLayout {
487    fn eq(&self, other: &Self) -> bool {
488        tessera_ui::LayoutModifierNode::placement_eq(self, other)
489    }
490}
491
492impl tessera_ui::LayoutModifierNode for PagerLayout {
493    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
494        let mut result = LayoutResult::default();
495        let children = input.children();
496        if self.page_count == 0 {
497            return Ok(result.with_size(ComputedData::min_from_constraint(
498                input.parent_constraint().as_ref(),
499            )));
500        }
501
502        if children.len() != self.visible_pages.len() {
503            return Err(MeasurementError::MeasureFnFailed(
504                "Pager measured child count mismatch".into(),
505            ));
506        }
507
508        let parent = input.parent_constraint();
509        let main_dimension = match self.axis {
510            PagerAxis::Horizontal => parent.width(),
511            PagerAxis::Vertical => parent.height(),
512        };
513        let cross_dimension = match self.axis {
514            PagerAxis::Horizontal => parent.height(),
515            PagerAxis::Vertical => parent.width(),
516        };
517
518        let page_main =
519            resolve_page_main_size(self.page_size, main_dimension, self.content_padding);
520        let page_spacing = self.page_spacing;
521        let padding = self.content_padding;
522        let container_main = main_dimension.clamp(page_main + padding + padding);
523
524        let cross_constraint =
525            cross_dimension_for_alignment(cross_dimension, self.cross_axis_alignment);
526        let child_constraint = match self.axis {
527            PagerAxis::Horizontal => {
528                Constraint::new(AxisConstraint::exact(page_main), cross_constraint)
529            }
530            PagerAxis::Vertical => {
531                Constraint::new(cross_constraint, AxisConstraint::exact(page_main))
532            }
533        };
534
535        let mut max_cross = Px::ZERO;
536        let mut measured_children = Vec::with_capacity(children.len());
537        for &child in &children {
538            let measurement = child.measure(input, &child_constraint)?;
539            max_cross = max_cross.max(self.axis.cross(measurement.size()));
540            measured_children.push((child, measurement.size()));
541        }
542        let container_cross = cross_dimension.clamp(max_cross);
543
544        let should_update_layout = self.controller.with(|controller| {
545            let mut next = controller.clone();
546            next.update_layout(page_main, page_spacing, self.page_count);
547            next != *controller
548        });
549        if should_update_layout {
550            self.controller.with_mut(|controller| {
551                controller.update_layout(page_main, page_spacing, self.page_count);
552            });
553        }
554
555        let scroll_offset = self.controller.with(|c| c.scroll_offset_px());
556        let page_step = page_main + page_spacing;
557
558        for (&child, &page_index) in children.iter().zip(self.visible_pages.iter()) {
559            let measured = measured_children
560                .iter()
561                .find_map(|(measured_child, size)| (*measured_child == child).then_some(*size))
562                .unwrap_or(ComputedData::ZERO);
563            let cross_offset = compute_cross_offset(
564                container_cross,
565                self.axis.cross(measured),
566                self.cross_axis_alignment,
567            );
568            let page_offset = padding + px_mul(page_step, page_index) + scroll_offset;
569            let position = self.axis.position(page_offset, cross_offset);
570            result.place_child(child, position);
571        }
572
573        Ok(result.with_size(self.axis.pack_size(container_main, container_cross)))
574    }
575
576    fn measure_eq(&self, other: &Self) -> bool {
577        self.axis == other.axis
578            && self.cross_axis_alignment == other.cross_axis_alignment
579            && self.page_size == other.page_size
580            && self.page_spacing == other.page_spacing
581            && self.content_padding == other.content_padding
582            && self.page_count == other.page_count
583            && self.visible_pages == other.visible_pages
584    }
585
586    fn placement_eq(&self, other: &Self) -> bool {
587        self.measure_eq(other) && self.scroll_offset == other.scroll_offset
588    }
589
590    fn place_children(
591        &self,
592        input: &PlacementScope<'_>,
593        _inner: &PlacementResult,
594    ) -> Option<PlacementResult> {
595        let mut result = LayoutResult::default();
596        if self.page_count == 0 {
597            return Some(result.into_placement_result());
598        }
599
600        let children = input.children();
601        if children.len() != self.visible_pages.len() {
602            return None;
603        }
604
605        let container_cross = self.axis.cross(input.size());
606        let page_step = self
607            .controller
608            .with(|controller| controller.page_size + controller.page_spacing);
609        let padding = self.content_padding;
610
611        for (&child, &page_index) in children.iter().zip(self.visible_pages.iter()) {
612            let measured = child.size();
613            let cross_offset = compute_cross_offset(
614                container_cross,
615                self.axis.cross(measured),
616                self.cross_axis_alignment,
617            );
618            let page_offset = padding + px_mul(page_step, page_index) + self.scroll_offset;
619            let position = self.axis.position(page_offset, cross_offset);
620            result.place_child(child, position);
621        }
622
623        Some(result.into_placement_result())
624    }
625}
626
627impl tessera_ui::DrawModifierNode for PagerLayout {
628    fn draw(
629        &self,
630        ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
631        content: &mut dyn tessera_ui::DrawModifierContent,
632    ) {
633        let input = &mut *ctx.render_input;
634        input.metadata_mut().set_clips_children(true);
635        content.draw(input);
636    }
637}
638
639#[derive(Clone, Copy, PartialEq, Eq, Hash)]
640struct ZeroLayout;
641
642impl tessera_ui::LayoutModifierNode for ZeroLayout {
643    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
644        Ok(LayoutResult::new(ComputedData::min_from_constraint(
645            input.parent_constraint().as_ref(),
646        )))
647    }
648}
649
650fn compute_visible_pages(current_page: usize, page_count: usize, beyond: usize) -> Vec<usize> {
651    if page_count == 0 {
652        return Vec::new();
653    }
654    let extra = beyond.saturating_add(1);
655    let start = current_page.saturating_sub(extra);
656    let end = (current_page + extra + 1).min(page_count);
657    (start..end).collect()
658}
659
660fn resolve_page_main_size(
661    page_size: PagerPageSize,
662    main_dimension: AxisConstraint,
663    padding: Px,
664) -> Px {
665    match page_size {
666        PagerPageSize::Fill => {
667            let max = main_dimension
668                .resolve_max()
669                .expect("Pager page size Fill requires a bounded main-axis constraint");
670            (max - padding - padding).max(Px::ZERO)
671        }
672        PagerPageSize::Fixed(dp) => dp.into(),
673    }
674}
675
676fn cross_dimension_for_alignment(
677    cross_dimension: AxisConstraint,
678    alignment: CrossAxisAlignment,
679) -> AxisConstraint {
680    match alignment {
681        // Deviation from `cross_axis_child_constraint`: a page must fill the
682        // cross axis even when the parent offers a loose interval, so `Stretch`
683        // normalizes to the exact maximum instead of inheriting the interval.
684        CrossAxisAlignment::Stretch => match cross_dimension.resolve_max() {
685            Some(max) => AxisConstraint::exact(max),
686            None => cross_dimension,
687        },
688        _ => cross_axis_child_constraint(cross_dimension, alignment),
689    }
690}
691
692fn compute_cross_offset(container: Px, child: Px, alignment: CrossAxisAlignment) -> Px {
693    match alignment {
694        CrossAxisAlignment::Start | CrossAxisAlignment::Stretch => Px::ZERO,
695        CrossAxisAlignment::Center => (container - child).max(Px::ZERO) / 2,
696        CrossAxisAlignment::End => (container - child).max(Px::ZERO),
697    }
698}
699
700fn sanitize_spacing(px: Px) -> Px {
701    if px < Px::ZERO { Px::ZERO } else { px }
702}
703
704struct PagerKeyboardModifierNode {
705    controller: State<PagerController>,
706    axis: PagerAxis,
707    user_scroll_enabled: bool,
708}
709
710impl KeyboardInputModifierNode for PagerKeyboardModifierNode {
711    fn on_keyboard_input(&self, mut input: KeyboardInput<'_>) {
712        if !self.user_scroll_enabled
713            || input.key_modifiers.control_key()
714            || input.key_modifiers.alt_key()
715            || input.key_modifiers.super_key()
716        {
717            return;
718        }
719
720        let mut handled = false;
721        for event in input.keyboard_events.iter() {
722            if event.state != winit::event::ElementState::Pressed {
723                continue;
724            }
725
726            let Some(command) = pager_keyboard_command(self.axis, &event.logical_key) else {
727                continue;
728            };
729
730            handled = run_pager_keyboard_command(self.controller, command);
731            if handled {
732                break;
733            }
734        }
735
736        if handled {
737            input.block_keyboard();
738        }
739    }
740}
741
742struct PagerPointerModifierNode {
743    controller: State<PagerController>,
744    axis: PagerAxis,
745    user_scroll_enabled: bool,
746    drag_recognizer: State<DragRecognizer>,
747    scroll_recognizer: State<ScrollRecognizer>,
748}
749
750impl PointerInputModifierNode for PagerPointerModifierNode {
751    fn on_pointer_input(&self, input: PointerInput<'_>) {
752        if !self.user_scroll_enabled {
753            return;
754        }
755
756        let is_cursor_in_component = input
757            .cursor_position_rel
758            .map(|pos| is_position_inside_bounds(input.computed_data, pos))
759            .unwrap_or(false);
760        let is_dragging = self.controller.with(|controller| controller.is_dragging());
761        if !is_cursor_in_component && !is_dragging {
762            return;
763        }
764
765        let frame_nanos = tessera_ui::current_frame_nanos();
766        let scroll_result = self.scroll_recognizer.with_mut(|recognizer| {
767            recognizer.update(input.pass, input.pointer_changes.as_mut_slice())
768        });
769        let (scroll_delta_x, scroll_delta_y) = normalize_pager_scroll_delta(
770            scroll_result.delta_x,
771            scroll_result.delta_y,
772            scroll_result.unit,
773            scroll_result.source,
774        );
775        let scroll_delta = self.axis.scroll_delta(scroll_delta_x, scroll_delta_y);
776
777        if scroll_delta.abs() >= 0.01 {
778            self.controller.with_mut(|controller| {
779                controller.apply_scroll_delta(scroll_delta, frame_nanos);
780                controller.end_drag();
781            });
782            return;
783        }
784
785        let drag_result = self.drag_recognizer.with_mut(|recognizer| {
786            recognizer.update(
787                input.pass,
788                input.pointer_changes.as_mut_slice(),
789                is_cursor_in_component,
790            )
791        });
792        if drag_result.started {
793            self.controller
794                .with_mut(|controller| controller.start_drag(frame_nanos));
795        }
796
797        let drag_delta = self.axis.scroll_delta(
798            input.local_delta(drag_result.delta_x.to_f32(), 0.0).0,
799            input.local_delta(0.0, drag_result.delta_y.to_f32()).1,
800        );
801        if drag_result.updated && drag_delta.abs() >= 0.01 {
802            self.controller
803                .with_mut(|controller| controller.apply_scroll_delta(drag_delta, frame_nanos));
804        }
805
806        if drag_result.ended {
807            self.controller.with_mut(|controller| controller.end_drag());
808        }
809    }
810}
811
812fn apply_pager_input_modifiers(
813    base: Modifier,
814    controller: State<PagerController>,
815    axis: PagerAxis,
816    user_scroll_enabled: bool,
817    drag_recognizer: State<DragRecognizer>,
818    scroll_recognizer: State<ScrollRecognizer>,
819) -> Modifier {
820    base.push_keyboard_input(PagerKeyboardModifierNode {
821        controller,
822        axis,
823        user_scroll_enabled,
824    })
825    .push_pointer_input(PagerPointerModifierNode {
826        controller,
827        axis,
828        user_scroll_enabled,
829        drag_recognizer,
830        scroll_recognizer,
831    })
832}
833
834/// # horizontal_pager
835///
836/// Renders a horizontally swipeable pager that snaps between full-width pages.
837///
838/// ## Usage
839///
840/// Build onboarding flows, image carousels, or horizontally paged dashboards.
841///
842/// ## Parameters
843///
844/// - `args` — configures paging, spacing, and layout behavior through the
845///   component's builder parameters.
846/// - `page_content` — closure that renders each page by index.
847///
848/// ## Examples
849///
850/// ```
851/// use tessera_components::pager::horizontal_pager;
852/// use tessera_components::text::text;
853/// use tessera_ui::tessera;
854/// # use tessera_components::theme::{MaterialTheme, material_theme};
855///
856/// #[tessera]
857/// fn demo() {
858///     material_theme()
859///         .theme(|| MaterialTheme::default())
860///         .child(move || {
861///             horizontal_pager()
862///                 .page_count(4)
863///                 .initial_page(2)
864///                 .page_content(|page| {
865///                     text(format!("Page {page}"));
866///                 });
867///         });
868/// }
869///
870/// # tessera_ui::testing::layout_test(demo).run();
871/// ```
872#[tessera]
873pub fn horizontal_pager(
874    modifier: Option<Modifier>,
875    page_count: Option<usize>,
876    initial_page: Option<usize>,
877    page_size: Option<PagerPageSize>,
878    page_spacing: Option<Dp>,
879    content_padding: Option<Dp>,
880    beyond_viewport_page_count: Option<usize>,
881    cross_axis_alignment: Option<CrossAxisAlignment>,
882    user_scroll_enabled: Option<bool>,
883    snap_threshold: Option<f32>,
884    scroll_smoothing: Option<f32>,
885    page_content: Option<CallbackWith<usize>>,
886    controller: Option<State<PagerController>>,
887) {
888    let page_count = page_count.unwrap_or(PagerConfig::default().page_count);
889    let initial_page = initial_page.unwrap_or(PagerConfig::default().initial_page);
890    let page_size = page_size.unwrap_or(PagerConfig::default().page_size);
891    let page_spacing = page_spacing.unwrap_or(PagerConfig::default().page_spacing);
892    let content_padding = content_padding.unwrap_or(PagerConfig::default().content_padding);
893    let beyond_viewport_page_count =
894        beyond_viewport_page_count.unwrap_or(PagerConfig::default().beyond_viewport_page_count);
895    let cross_axis_alignment =
896        cross_axis_alignment.unwrap_or(PagerConfig::default().cross_axis_alignment);
897    let user_scroll_enabled =
898        user_scroll_enabled.unwrap_or(PagerConfig::default().user_scroll_enabled);
899    let pager_args = pager_config_from_params(PagerParams {
900        modifier,
901        page_count,
902        initial_page,
903        page_size,
904        page_spacing,
905        content_padding,
906        beyond_viewport_page_count,
907        cross_axis_alignment,
908        user_scroll_enabled,
909        snap_threshold,
910        scroll_smoothing,
911        page_content,
912        controller,
913    });
914    let controller = match pager_args.controller {
915        Some(value) => value,
916        None => remember(|| PagerController::new(pager_args.initial_page)),
917    };
918    let drag_recognizer = remember(|| {
919        DragRecognizer::new(DragSettings {
920            axis: Some(DragAxis::Horizontal),
921            ..DragSettings::default()
922        })
923    });
924    let scroll_recognizer = remember(|| ScrollRecognizer::new(ScrollSettings { consume: true }));
925    let frame_nanos = tessera_ui::current_frame_nanos();
926    if controller.with(|current| current.has_pending_animation_frame(frame_nanos)) {
927        let snap_threshold = pager_args.snap_threshold;
928        let scroll_smoothing = pager_args.scroll_smoothing;
929        receive_frame_nanos(move |frame_nanos| {
930            let has_pending_animation_frame = controller.with_mut(|current| {
931                current.tick(frame_nanos, snap_threshold, scroll_smoothing);
932                current.has_pending_animation_frame(frame_nanos)
933            });
934            if has_pending_animation_frame {
935                tessera_ui::FrameNanosControl::Continue
936            } else {
937                tessera_ui::FrameNanosControl::Stop
938            }
939        });
940    }
941    pager_render(
942        pager_args,
943        controller,
944        PagerAxis::Horizontal,
945        drag_recognizer,
946        scroll_recognizer,
947    );
948}
949
950/// # vertical_pager
951///
952/// Renders a vertically swipeable pager that snaps between full-height pages.
953///
954/// ## Usage
955///
956/// Build vertically paged dashboards, step-by-step forms, or stacked galleries.
957///
958/// ## Parameters
959///
960/// - `args` — configures paging, spacing, and layout behavior through the
961///   component's builder parameters.
962/// - `page_content` — closure that renders each page by index.
963///
964/// ## Examples
965///
966/// ```
967/// use tessera_components::pager::vertical_pager;
968/// use tessera_components::text::text;
969/// use tessera_ui::tessera;
970/// # use tessera_components::theme::{MaterialTheme, material_theme};
971///
972/// #[tessera]
973/// fn demo() {
974///     material_theme()
975///         .theme(|| MaterialTheme::default())
976///         .child(move || {
977///             vertical_pager().page_count(2).page_content(|page| {
978///                 text(format!("Page {page}"));
979///             });
980///         });
981/// }
982///
983/// # tessera_ui::testing::layout_test(demo).run();
984/// ```
985#[tessera]
986pub fn vertical_pager(
987    modifier: Option<Modifier>,
988    page_count: Option<usize>,
989    initial_page: Option<usize>,
990    page_size: Option<PagerPageSize>,
991    page_spacing: Option<Dp>,
992    content_padding: Option<Dp>,
993    beyond_viewport_page_count: Option<usize>,
994    cross_axis_alignment: Option<CrossAxisAlignment>,
995    user_scroll_enabled: Option<bool>,
996    snap_threshold: Option<f32>,
997    scroll_smoothing: Option<f32>,
998    page_content: Option<CallbackWith<usize>>,
999    controller: Option<State<PagerController>>,
1000) {
1001    let page_count = page_count.unwrap_or(PagerConfig::default().page_count);
1002    let initial_page = initial_page.unwrap_or(PagerConfig::default().initial_page);
1003    let page_size = page_size.unwrap_or(PagerConfig::default().page_size);
1004    let page_spacing = page_spacing.unwrap_or(PagerConfig::default().page_spacing);
1005    let content_padding = content_padding.unwrap_or(PagerConfig::default().content_padding);
1006    let beyond_viewport_page_count =
1007        beyond_viewport_page_count.unwrap_or(PagerConfig::default().beyond_viewport_page_count);
1008    let cross_axis_alignment =
1009        cross_axis_alignment.unwrap_or(PagerConfig::default().cross_axis_alignment);
1010    let user_scroll_enabled =
1011        user_scroll_enabled.unwrap_or(PagerConfig::default().user_scroll_enabled);
1012    let pager_args = pager_config_from_params(PagerParams {
1013        modifier,
1014        page_count,
1015        initial_page,
1016        page_size,
1017        page_spacing,
1018        content_padding,
1019        beyond_viewport_page_count,
1020        cross_axis_alignment,
1021        user_scroll_enabled,
1022        snap_threshold,
1023        scroll_smoothing,
1024        page_content,
1025        controller,
1026    });
1027    let controller = match pager_args.controller {
1028        Some(value) => value,
1029        None => remember(|| PagerController::new(pager_args.initial_page)),
1030    };
1031    let drag_recognizer = remember(|| {
1032        DragRecognizer::new(DragSettings {
1033            axis: Some(DragAxis::Vertical),
1034            ..DragSettings::default()
1035        })
1036    });
1037    let scroll_recognizer = remember(|| ScrollRecognizer::new(ScrollSettings { consume: true }));
1038    let frame_nanos = tessera_ui::current_frame_nanos();
1039    if controller.with(|current| current.has_pending_animation_frame(frame_nanos)) {
1040        let snap_threshold = pager_args.snap_threshold;
1041        let scroll_smoothing = pager_args.scroll_smoothing;
1042        receive_frame_nanos(move |frame_nanos| {
1043            let has_pending_animation_frame = controller.with_mut(|current| {
1044                current.tick(frame_nanos, snap_threshold, scroll_smoothing);
1045                current.has_pending_animation_frame(frame_nanos)
1046            });
1047            if has_pending_animation_frame {
1048                tessera_ui::FrameNanosControl::Continue
1049            } else {
1050                tessera_ui::FrameNanosControl::Stop
1051            }
1052        });
1053    }
1054    pager_render(
1055        pager_args,
1056        controller,
1057        PagerAxis::Vertical,
1058        drag_recognizer,
1059        scroll_recognizer,
1060    );
1061}
1062
1063#[tessera]
1064fn pager_render(
1065    args: PagerConfig,
1066    controller: State<PagerController>,
1067    axis: PagerAxis,
1068    drag_recognizer: State<DragRecognizer>,
1069    scroll_recognizer: State<ScrollRecognizer>,
1070) {
1071    let page_content = args.page_content;
1072    let should_set_page_count = controller.with(|current| {
1073        let mut next = current.clone();
1074        next.set_page_count(args.page_count);
1075        next != *current
1076    });
1077    if should_set_page_count {
1078        controller.with_mut(|current| current.set_page_count(args.page_count));
1079    }
1080
1081    let current_page = controller.with(|current| current.current_page());
1082    let visible_pages = compute_visible_pages(
1083        current_page,
1084        args.page_count,
1085        args.beyond_viewport_page_count,
1086    );
1087
1088    if visible_pages.is_empty() {
1089        layout().modifier(args.modifier.clone()).policy(ZeroLayout);
1090        return;
1091    }
1092
1093    let modifier = apply_pager_input_modifiers(
1094        args.modifier.clone().focusable().focus_properties(
1095            FocusProperties::new()
1096                .can_focus(args.user_scroll_enabled && args.page_count > 1)
1097                .can_request_focus(args.user_scroll_enabled && args.page_count > 1),
1098        ),
1099        controller,
1100        axis,
1101        args.user_scroll_enabled,
1102        drag_recognizer,
1103        scroll_recognizer,
1104    );
1105
1106    let policy = PagerLayout {
1107        axis,
1108        cross_axis_alignment: args.cross_axis_alignment,
1109        page_size: args.page_size,
1110        page_spacing: sanitize_spacing(Px::from(args.page_spacing)),
1111        content_padding: sanitize_spacing(Px::from(args.content_padding)),
1112        page_count: args.page_count,
1113        visible_pages: visible_pages.clone(),
1114        scroll_offset: controller.with(|current| current.scroll_offset_px()),
1115        controller,
1116    };
1117    layout()
1118        .modifier(modifier)
1119        .policy(policy.clone())
1120        .draw(policy)
1121        .child(move || {
1122            for page_index in visible_pages.iter().copied() {
1123                key(page_index, || {
1124                    page_content.call(page_index);
1125                });
1126            }
1127        });
1128}
1129
1130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1131enum PagerKeyboardCommand {
1132    Previous,
1133    Next,
1134    First,
1135    Last,
1136}
1137
1138fn pager_keyboard_command(
1139    axis: PagerAxis,
1140    logical_key: &winit::keyboard::Key,
1141) -> Option<PagerKeyboardCommand> {
1142    use winit::keyboard::{Key, NamedKey};
1143
1144    match logical_key {
1145        Key::Named(NamedKey::Home) => Some(PagerKeyboardCommand::First),
1146        Key::Named(NamedKey::End) => Some(PagerKeyboardCommand::Last),
1147        Key::Named(NamedKey::PageUp) => Some(PagerKeyboardCommand::Previous),
1148        Key::Named(NamedKey::PageDown) => Some(PagerKeyboardCommand::Next),
1149        Key::Named(NamedKey::ArrowLeft) if axis == PagerAxis::Horizontal => {
1150            Some(PagerKeyboardCommand::Previous)
1151        }
1152        Key::Named(NamedKey::ArrowRight) if axis == PagerAxis::Horizontal => {
1153            Some(PagerKeyboardCommand::Next)
1154        }
1155        Key::Named(NamedKey::ArrowUp) if axis == PagerAxis::Vertical => {
1156            Some(PagerKeyboardCommand::Previous)
1157        }
1158        Key::Named(NamedKey::ArrowDown) if axis == PagerAxis::Vertical => {
1159            Some(PagerKeyboardCommand::Next)
1160        }
1161        _ => None,
1162    }
1163}
1164
1165fn run_pager_keyboard_command(
1166    controller: State<PagerController>,
1167    command: PagerKeyboardCommand,
1168) -> bool {
1169    controller.with_mut(|controller| {
1170        if controller.page_count <= 1 {
1171            return false;
1172        }
1173
1174        let target = match command {
1175            PagerKeyboardCommand::Previous => {
1176                if controller.current_page == 0 {
1177                    return false;
1178                }
1179                controller.current_page - 1
1180            }
1181            PagerKeyboardCommand::Next => {
1182                if controller.current_page + 1 >= controller.page_count {
1183                    return false;
1184                }
1185                controller.current_page + 1
1186            }
1187            PagerKeyboardCommand::First => {
1188                if controller.current_page == 0 {
1189                    return false;
1190                }
1191                0
1192            }
1193            PagerKeyboardCommand::Last => {
1194                let last_page = controller.page_count - 1;
1195                if controller.current_page == last_page {
1196                    return false;
1197                }
1198                last_page
1199            }
1200        };
1201
1202        controller.scroll_to_page(target);
1203        true
1204    })
1205}