Skip to main content

tessera_components/scrollable/
scrollbar.rs

1//! Scrollbar components for scrollable viewports.
2//!
3//! ## Usage
4//!
5//! Show and drag scroll thumbs beside overflowing content.
6
7use tessera_foundation::gesture::{DragRecognizer, ScrollRecognizer, TapRecognizer};
8use tessera_ui::{
9    AccessibilityActionHandler, AccessibilityNode, AxisConstraint, Color, ComputedData, Constraint,
10    Dp, LayoutResult, MeasurementError, Modifier, Px, PxPosition, SemanticsModifierNode, State,
11    accesskit::{Action, Role},
12    current_frame_nanos,
13    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
14    modifier::ModifierCapabilityExt as _,
15    receive_frame_nanos, remember, tessera, use_context,
16};
17
18use crate::{
19    modifier::{ModifierExt as _, with_pointer_input},
20    nested_scroll::NestedScrollConnection,
21    scrollable::{ScrollBarBehavior, ScrollableController, ScrollbarWheelModifierNode},
22    shape_def::Shape,
23    surface::surface,
24    theme::{MaterialColorScheme, MaterialTheme},
25};
26
27/// Material Design 3 defaults for the scrollbar components.
28///
29/// A Material 3 scrollbar is a rounded thumb over the content; it has no
30/// persistent track. The thumb uses the `on-surface` hue at a low opacity so
31/// it stays legible on both light and dark surfaces.
32///
33/// The opacities are tuned for the renderer's linear-space compositing so the
34/// resting thumb keeps a comparable weight to a Material scrollbar on a light
35/// surface.
36pub struct ScrollbarDefaults;
37
38impl ScrollbarDefaults {
39    /// Default scrollbar thickness.
40    pub const THICKNESS: Dp = Dp(8.0);
41
42    /// Default thumb opacity over the surface.
43    pub const THUMB_ALPHA: f32 = 0.5;
44
45    /// Thumb opacity while the pointer hovers or drags the thumb.
46    pub const THUMB_HOVER_ALPHA: f32 = 0.7;
47
48    /// Returns the default thumb color for the provided scheme.
49    pub fn thumb_color(scheme: &MaterialColorScheme) -> Color {
50        scheme.on_surface.with_alpha(Self::THUMB_ALPHA)
51    }
52
53    /// Returns the thumb color used while hovering or dragging.
54    pub fn thumb_hover_color(scheme: &MaterialColorScheme) -> Color {
55        scheme.on_surface.with_alpha(Self::THUMB_HOVER_ALPHA)
56    }
57}
58
59#[derive(Clone, PartialEq, Copy, Default)]
60enum ScrollOrientation {
61    #[default]
62    Vertical,
63    Horizontal,
64}
65
66impl ScrollOrientation {
67    fn position(self, main: Px, cross: Px) -> PxPosition {
68        match self {
69            Self::Vertical => PxPosition::new(cross, main),
70            Self::Horizontal => PxPosition::new(main, cross),
71        }
72    }
73
74    fn extent(self, size: ComputedData) -> Px {
75        match self {
76            Self::Vertical => size.height,
77            Self::Horizontal => size.width,
78        }
79    }
80
81    fn size(self, main: Px, cross: Px) -> tessera_ui::PxSize {
82        match self {
83            Self::Vertical => tessera_ui::PxSize::new(cross, main),
84            Self::Horizontal => tessera_ui::PxSize::new(main, cross),
85        }
86    }
87
88    fn axis_px(self, point: PxPosition) -> Px {
89        match self {
90            Self::Vertical => point.y_px(),
91            Self::Horizontal => point.x_px(),
92        }
93    }
94
95    fn cursor(self, point: PxPosition) -> (f32, f32) {
96        match self {
97            Self::Vertical => (point.y, point.x),
98            Self::Horizontal => (point.x, point.y),
99        }
100    }
101}
102
103#[derive(Clone, Copy, Default, PartialEq, Eq)]
104struct ZeroLayout;
105
106impl tessera_ui::LayoutModifierNode for ZeroLayout {
107    fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
108        Ok(LayoutResult::new(ComputedData::ZERO))
109    }
110}
111
112const HOVER_FADE_DURATION_SECS: f32 = 0.2;
113const AUTO_HIDE_TIMEOUT_SECS: f32 = 2.0;
114
115fn elapsed_secs_from_frame_nanos(current_frame_nanos: u64, start_frame_nanos: u64) -> f32 {
116    current_frame_nanos.saturating_sub(start_frame_nanos) as f32 / 1_000_000_000.0
117}
118
119#[derive(Clone, PartialEq)]
120struct ScrollBarLayout {
121    thumb_offset: PxPosition,
122}
123
124impl tessera_ui::LayoutModifierNode for ScrollBarLayout {
125    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
126        let mut result = LayoutResult::default();
127        let children = input.children();
128        let track = children[0];
129        let size = track.measure(input, &Constraint::NONE)?;
130        result.place_child(track, PxPosition::ZERO);
131
132        let thumb = children[1];
133        thumb.measure(input, &Constraint::NONE)?;
134        result.place_child(thumb, self.thumb_offset);
135
136        Ok(result.with_size(size.size()))
137    }
138
139    fn measure_eq(&self, _other: &Self) -> bool {
140        true
141    }
142
143    fn placement_eq(&self, other: &Self) -> bool {
144        self.thumb_offset == other.thumb_offset
145    }
146
147    fn place_children(
148        &self,
149        input: &PlacementScope<'_>,
150        _inner: &PlacementResult,
151    ) -> Option<PlacementResult> {
152        let mut result = LayoutResult::default();
153        let children = input.children();
154        let Some(&track) = children.first() else {
155            return Some(result.into_placement_result());
156        };
157        result.place_child(track, PxPosition::ZERO);
158        let Some(&thumb) = children.get(1) else {
159            return Some(result.into_placement_result());
160        };
161        result.place_child(thumb, self.thumb_offset);
162        Some(result.into_placement_result())
163    }
164}
165
166#[derive(Clone, PartialEq)]
167struct ScrollBarConfig {
168    total: Px,
169    visible: Px,
170    offset: Px,
171    thickness: Dp,
172    state: State<ScrollableController>,
173    scrollbar_behavior: ScrollBarBehavior,
174    track_color: Color,
175    thumb_color: Color,
176    thumb_hover_color: Color,
177    scrollbar_state: Option<ScrollBarState>,
178}
179
180#[derive(Clone, Copy, PartialEq, Default)]
181/// Mutable state shared by a scrollbar and its viewport.
182pub struct ScrollBarStateInner {
183    /// Whether the scrollbar's thumb is currently being dragged.
184    pub is_dragging: bool,
185    /// Whether the active pointer press started on the thumb.
186    pub pressed_on_thumb: bool,
187    /// Whether the scrollbar's thumb is currently being hovered.
188    pub is_hovered: bool,
189    /// The frame timestamp when the hover state last changed.
190    pub hover_start_frame_nanos: Option<u64>,
191    /// The frame timestamp when the last scroll activity occurred (for AutoHide
192    /// behavior).
193    pub last_scroll_activity_frame_nanos: Option<u64>,
194    /// Whether the scrollbar should be visible (for AutoHide behavior).
195    pub should_be_visible: bool,
196}
197
198/// An explicit owner of the interaction state shared by a scrollbar and viewport.
199#[derive(Clone, Copy, PartialEq)]
200pub struct ScrollBarState {
201    owner: ScrollBarOwner,
202}
203
204#[derive(Clone, Copy, PartialEq)]
205enum ScrollBarOwner {
206    External(State<ScrollBarStateInner>),
207    Viewport {
208        controller: State<ScrollableController>,
209        vertical: bool,
210    },
211}
212
213impl ScrollBarState {
214    /// Shares a component-owned scrollbar interaction state.
215    pub fn new(state: State<ScrollBarStateInner>) -> Self {
216        Self {
217            owner: ScrollBarOwner::External(state),
218        }
219    }
220
221    pub(crate) fn viewport(controller: State<ScrollableController>, vertical: bool) -> Self {
222        Self {
223            owner: ScrollBarOwner::Viewport {
224                controller,
225                vertical,
226            },
227        }
228    }
229
230    /// Reads the current interaction state.
231    pub fn read(&self) -> ScrollBarStateInner {
232        match self.owner {
233            ScrollBarOwner::External(state) => state.get(),
234            ScrollBarOwner::Viewport {
235                controller,
236                vertical,
237            } => controller.with(|controller| {
238                if vertical {
239                    controller.scrollbar_state_v
240                } else {
241                    controller.scrollbar_state_h
242                }
243            }),
244        }
245    }
246
247    /// Updates the owning state and invalidates its readers.
248    pub fn with_mut<R>(&self, update: impl FnOnce(&mut ScrollBarStateInner) -> R) -> R {
249        match self.owner {
250            ScrollBarOwner::External(state) => state.with_mut(update),
251            ScrollBarOwner::Viewport {
252                controller,
253                vertical,
254            } => controller.with_mut(|controller| {
255                update(if vertical {
256                    &mut controller.scrollbar_state_v
257                } else {
258                    &mut controller.scrollbar_state_h
259                })
260            }),
261        }
262    }
263}
264
265fn calculate_target_pos(
266    orientation: ScrollOrientation,
267    cursor: Px,
268    track_extent: Px,
269    thumb_extent: Px,
270    total: Px,
271    visible: Px,
272    fallback: PxPosition,
273) -> PxPosition {
274    // If the thumb cannot move, return the provided fallback (avoids locking
275    // inside this helper).
276    let thumb_scrollable_range = track_extent - thumb_extent;
277    if thumb_scrollable_range <= Px::ZERO {
278        return fallback;
279    }
280
281    let cursor_adjusted = cursor - thumb_extent / 2;
282    let progress = (cursor_adjusted.to_f32() / thumb_scrollable_range.to_f32()).clamp(0.0, 1.0);
283
284    let content_scrollable_range = total - visible;
285    if content_scrollable_range <= Px::ZERO {
286        return PxPosition::ZERO;
287    }
288
289    let target = Px::from_f32(-progress * content_scrollable_range.to_f32());
290    orientation.position(target, Px::ZERO)
291}
292
293fn compute_thumb_color(
294    state: &ScrollBarStateInner,
295    thumb_color: Color,
296    thumb_hover_color: Color,
297    frame_nanos: u64,
298) -> Color {
299    // Before the first hover there is no previous color to fade from, so the
300    // thumb starts at its resting color.
301    let Some(hover_start_frame_nanos) = state.hover_start_frame_nanos else {
302        return thumb_color;
303    };
304    let progress = (elapsed_secs_from_frame_nanos(frame_nanos, hover_start_frame_nanos)
305        / HOVER_FADE_DURATION_SECS)
306        .min(1.0);
307    if state.is_hovered {
308        thumb_color.lerp(&thumb_hover_color, progress)
309    } else {
310        thumb_hover_color.lerp(&thumb_color, progress)
311    }
312}
313fn should_show_scrollbar(args: &ScrollBarConfig, state: &ScrollBarState) -> bool {
314    match args.scrollbar_behavior {
315        ScrollBarBehavior::AlwaysVisible => true,
316        ScrollBarBehavior::Hidden => false,
317        ScrollBarBehavior::AutoHide => {
318            let state_guard = state.read();
319            state_guard.should_be_visible || state_guard.is_dragging || state_guard.is_hovered
320        }
321    }
322}
323
324fn handle_autohide_if_needed(args: &ScrollBarConfig, state: &ScrollBarState, frame_nanos: u64) {
325    if matches!(args.scrollbar_behavior, ScrollBarBehavior::AutoHide) {
326        let current = state.read();
327        if current.should_be_visible
328            && current
329                .last_scroll_activity_frame_nanos
330                .is_some_and(|last| {
331                    elapsed_secs_from_frame_nanos(frame_nanos, last) > AUTO_HIDE_TIMEOUT_SECS
332                })
333        {
334            state.with_mut(|state| state.should_be_visible = false);
335        }
336    }
337}
338
339fn needs_scrollbar_frame_tick(
340    args: &ScrollBarConfig,
341    state: &ScrollBarState,
342    frame_nanos: u64,
343) -> bool {
344    let state_guard = state.read();
345
346    let hover_animating =
347        state_guard
348            .hover_start_frame_nanos
349            .is_some_and(|hover_start_frame_nanos| {
350                elapsed_secs_from_frame_nanos(frame_nanos, hover_start_frame_nanos)
351                    < HOVER_FADE_DURATION_SECS
352            });
353    if hover_animating || state_guard.is_dragging {
354        return true;
355    }
356
357    if !matches!(args.scrollbar_behavior, ScrollBarBehavior::AutoHide) {
358        return false;
359    }
360
361    state_guard.should_be_visible
362        || state_guard.is_hovered
363        || state_guard.last_scroll_activity_frame_nanos.is_some_and(
364            |last_scroll_activity_frame_nanos| {
365                elapsed_secs_from_frame_nanos(frame_nanos, last_scroll_activity_frame_nanos)
366                    < AUTO_HIDE_TIMEOUT_SECS
367            },
368        )
369}
370
371fn mark_scroll_activity(state: &ScrollBarState, behavior: &ScrollBarBehavior, frame_nanos: u64) {
372    if matches!(*behavior, ScrollBarBehavior::AutoHide) {
373        state.with_mut(|state_guard| {
374            state_guard.last_scroll_activity_frame_nanos = Some(frame_nanos);
375            state_guard.should_be_visible = true;
376        });
377    }
378}
379
380fn compute_thumb_progress(offset: Px, total: Px) -> f32 {
381    if total <= Px::ZERO {
382        0.0
383    } else {
384        offset.to_f32().abs() / total.to_f32()
385    }
386}
387
388fn compute_thumb_size(visible: Px, total: Px) -> Px {
389    if total <= Px::ZERO {
390        return visible.max(Px::ZERO);
391    }
392    let visible_len = visible.to_f32().abs();
393    let total_len = total.to_f32().abs().max(1.0);
394    let thumb = (visible_len * visible_len) / total_len;
395
396    // Clamp the thumb size to ensure it's always visible and provides a
397    // reasonable drag target.
398    let min_thumb = (visible_len * 0.05).clamp(8.0, 32.0);
399    Px::saturating_from_f32(thumb.max(min_thumb))
400}
401
402fn cursor_on_thumb(
403    orientation: ScrollOrientation,
404    cursor: PxPosition,
405    thickness: Px,
406    offset: f32,
407    extent: Px,
408) -> bool {
409    let (main, cross) = orientation.cursor(cursor);
410    cross >= 0.0
411        && cross <= thickness.to_f32()
412        && main >= offset
413        && main <= offset + extent.to_f32()
414}
415
416fn apply_scrollbar_semantics(
417    accessibility: &mut AccessibilityNode,
418    action_handler: &mut Option<AccessibilityActionHandler>,
419    args: &ScrollBarConfig,
420    state: &ScrollBarState,
421    orientation: ScrollOrientation,
422) {
423    accessibility.role = Some(Role::ScrollBar);
424    accessibility.label = Some(
425        match orientation {
426            ScrollOrientation::Vertical => "Vertical scrollbar",
427            ScrollOrientation::Horizontal => "Horizontal scrollbar",
428        }
429        .to_string(),
430    );
431
432    let progress = compute_thumb_progress(args.offset, args.total).clamp(0.0, 1.0);
433    accessibility.numeric_value = Some(progress as f64);
434    accessibility.min_numeric_value = Some(0.0);
435    accessibility.max_numeric_value = Some(1.0);
436    accessibility.focusable = true;
437    accessibility.actions.clear();
438    accessibility.actions.push(Action::Increment);
439    accessibility.actions.push(Action::Decrement);
440
441    let args_clone = args.clone();
442    let state_clone = *state;
443    *action_handler = Some(Box::new(move |action| match action {
444        Action::Increment => {
445            scroll_accessibility_step(&args_clone, &state_clone, orientation, true)
446        }
447        Action::Decrement => {
448            scroll_accessibility_step(&args_clone, &state_clone, orientation, false)
449        }
450        _ => {}
451    }));
452}
453
454struct ScrollbarSemanticsModifierNode {
455    args: ScrollBarConfig,
456    state: ScrollBarState,
457    orientation: ScrollOrientation,
458}
459
460impl SemanticsModifierNode for ScrollbarSemanticsModifierNode {
461    fn apply(
462        &self,
463        accessibility: &mut AccessibilityNode,
464        action_handler: &mut Option<AccessibilityActionHandler>,
465    ) {
466        apply_scrollbar_semantics(
467            accessibility,
468            action_handler,
469            &self.args,
470            &self.state,
471            self.orientation,
472        );
473    }
474}
475
476fn scroll_accessibility_step(
477    args: &ScrollBarConfig,
478    state: &ScrollBarState,
479    orientation: ScrollOrientation,
480    increment: bool,
481) {
482    let step_amount = (args.visible.to_f32() * 0.1).max(1.0);
483    let step_px = Px::from_f32(step_amount);
484    let delta = match orientation {
485        ScrollOrientation::Vertical => {
486            let dy = if increment { -step_px } else { step_px };
487            PxPosition::new(Px::ZERO, dy)
488        }
489        ScrollOrientation::Horizontal => {
490            let dx = if increment { -step_px } else { step_px };
491            PxPosition::new(dx, Px::ZERO)
492        }
493    };
494
495    let new_target = args.state.with(|c| {
496        c.target_position()
497            .saturating_offset(delta.x_px(), delta.y_px())
498    });
499    args.state.with_mut(|c| c.set_target_position(new_target));
500
501    mark_scroll_activity(state, &args.scrollbar_behavior, current_frame_nanos());
502}
503
504fn update_drag(
505    orientation: ScrollOrientation,
506    input: &tessera_ui::PointerInput,
507    calculate_target: &dyn Fn(Px) -> PxPosition,
508    args: &ScrollBarConfig,
509    state: &ScrollBarState,
510    frame_nanos: u64,
511) {
512    if let Some(cursor_pos) = input.cursor_position_rel {
513        let new_target_pos = calculate_target(orientation.axis_px(cursor_pos));
514        // Dragging the thumb is direct manipulation: the content has to follow
515        // the pointer without the eased follow used for wheel notches.
516        args.state
517            .with_mut(|c| c.set_scroll_position(new_target_pos));
518        mark_scroll_activity(state, &args.scrollbar_behavior, frame_nanos);
519    } else {
520        // Cursor left window: stop dragging.
521        state.with_mut(|state| state.is_dragging = false);
522    }
523}
524
525fn update_hover_state(is_on_thumb: bool, state: &ScrollBarState, frame_nanos: u64) {
526    if is_on_thumb && !state.read().is_hovered {
527        state.with_mut(|state_guard| {
528            state_guard.is_hovered = true;
529            state_guard.hover_start_frame_nanos = Some(frame_nanos);
530        });
531    } else if !is_on_thumb && state.read().is_hovered {
532        state.with_mut(|state_guard| {
533            state_guard.is_hovered = false;
534            state_guard.hover_start_frame_nanos = Some(frame_nanos);
535        });
536    }
537}
538
539#[derive(Clone, Copy)]
540struct ScrollBarTrackMetrics {
541    track_extent: Px,
542    thumb_extent: Px,
543}
544
545fn handle_state(
546    orientation: ScrollOrientation,
547    args: &ScrollBarConfig,
548    state: &ScrollBarState,
549    metrics: ScrollBarTrackMetrics,
550    tap_recognizer: State<TapRecognizer>,
551    drag_recognizer: State<DragRecognizer>,
552    input: &mut tessera_ui::PointerInput<'_>,
553    frame_nanos: u64,
554) {
555    handle_autohide_if_needed(args, state, frame_nanos);
556
557    // Capture current target position once to avoid locking inside helper on
558    // every call.
559    let fallback_pos = args.state.with(|c| c.target_position());
560    let calculate_target_pos = |cursor: Px| -> PxPosition {
561        calculate_target_pos(
562            orientation,
563            cursor,
564            metrics.track_extent,
565            metrics.thumb_extent,
566            args.total,
567            args.visible,
568            fallback_pos,
569        )
570    };
571    let thickness = args.thickness.to_px();
572    let cursor_pos = input.cursor_position_rel;
573    let within_track = cursor_pos
574        .map(|pos| cursor_on_thumb(orientation, pos, thickness, 0.0, metrics.track_extent))
575        .unwrap_or(false);
576    let thumb_offset = args.visible.to_f32() * (args.offset.to_f32().abs() / args.total.to_f32());
577    let is_on_thumb = cursor_pos
578        .map(|pos| {
579            cursor_on_thumb(
580                orientation,
581                pos,
582                thickness,
583                thumb_offset,
584                metrics.thumb_extent,
585            )
586        })
587        .unwrap_or(false);
588    update_hover_state(is_on_thumb, state, frame_nanos);
589    let tap_result = tap_recognizer.with_mut(|recognizer| {
590        recognizer.update(
591            input.pass,
592            input.pointer_changes.as_mut_slice(),
593            within_track,
594        )
595    });
596    let drag_result = drag_recognizer.with_mut(|recognizer| {
597        recognizer.update(
598            input.pass,
599            input.pointer_changes.as_mut_slice(),
600            within_track,
601        )
602    });
603
604    if tap_result.pressed {
605        if is_on_thumb {
606            state.with_mut(|state| state.pressed_on_thumb = true);
607        } else if within_track && let Some(cursor_pos) = cursor_pos {
608            let new_target_pos = calculate_target_pos(orientation.axis_px(cursor_pos));
609            args.state
610                .with_mut(|c| c.set_target_position(new_target_pos));
611            mark_scroll_activity(state, &args.scrollbar_behavior, frame_nanos);
612        }
613    }
614
615    if drag_result.started && state.read().pressed_on_thumb {
616        state.with_mut(|state| state.is_dragging = true);
617    }
618
619    if state.read().is_dragging && drag_result.updated {
620        update_drag(
621            orientation,
622            input,
623            &calculate_target_pos,
624            args,
625            state,
626            frame_nanos,
627        );
628    } else if state.read().is_dragging && cursor_pos.is_none() {
629        state.with_mut(|state_guard| {
630            state_guard.is_dragging = false;
631            state_guard.pressed_on_thumb = false;
632        });
633    }
634
635    if tap_result.released || drag_result.ended {
636        state.with_mut(|state_guard| {
637            state_guard.is_dragging = false;
638            state_guard.pressed_on_thumb = false;
639        });
640    }
641}
642
643/// # scrollbar_v
644///
645/// Draw a vertical scrollbar for a scroll viewport.
646///
647/// ## Usage
648///
649/// Show and drag the vertical offset of overflowing content.
650///
651/// ## Parameters
652///
653/// - `total` — content extent in physical pixels; defaults to controller
654///   content.
655/// - `visible` — viewport extent in physical pixels; defaults to controller
656///   viewport.
657/// - `offset` — current scroll offset; defaults to the controller position.
658/// - `thickness` — thickness of the scrollbar track.
659/// - `state` — scroll controller the scrollbar reads and drives.
660/// - `scrollbar_behavior` — visibility behavior (always, auto-hide, hidden).
661/// - `track_color`, `thumb_color`, `thumb_hover_color` — themed colors.
662/// - `scrollbar_state` — shared interaction state; created internally when
663///   absent.
664///
665/// Wheel input follows the attached viewport. A custom track without a viewport
666/// retains tap, drag and accessibility actions and leaves wheel input to
667/// ancestors.
668///
669/// ## Examples
670///
671/// ```
672/// use tessera_components::scrollable::{ScrollableController, scrollbar_v};
673/// use tessera_components::theme::material_theme;
674/// use tessera_ui::{Px, PxPosition, remember, tessera};
675/// #[tessera]
676/// fn tracked_content() {
677///     material_theme().child(|| {
678///         let controller = remember(ScrollableController::new);
679///         let offset = PxPosition::new(Px::ZERO, Px(-40));
680///         controller.with_mut(|controller| controller.set_scroll_position(offset));
681///         assert_eq!(
682///             controller.with(|controller| controller.child_position()),
683///             offset
684///         );
685///         scrollbar_v()
686///             .state(controller)
687///             .total(Px(600))
688///             .visible(Px(200));
689///     });
690/// }
691/// # tessera_ui::testing::layout_test(tracked_content).run();
692/// ```
693#[tessera]
694pub fn scrollbar_v(
695    total: Option<Px>,
696    visible: Option<Px>,
697    offset: Option<Px>,
698    thickness: Option<Dp>,
699    state: Option<State<ScrollableController>>,
700    scrollbar_behavior: Option<ScrollBarBehavior>,
701    track_color: Option<Color>,
702    thumb_color: Option<Color>,
703    thumb_hover_color: Option<Color>,
704    scrollbar_state: Option<ScrollBarState>,
705) {
706    scrollbar(
707        ScrollOrientation::Vertical,
708        state.expect("scrollbar_v requires state"),
709    )
710    .total_optional(total)
711    .visible_optional(visible)
712    .offset_optional(offset)
713    .thickness_optional(thickness)
714    .scrollbar_behavior_optional(scrollbar_behavior)
715    .track_color_optional(track_color)
716    .thumb_color_optional(thumb_color)
717    .thumb_hover_color_optional(thumb_hover_color)
718    .scrollbar_state_optional(scrollbar_state);
719}
720
721/// # scrollbar_h
722///
723/// Draw a horizontal scrollbar for a scroll viewport.
724///
725/// ## Usage
726///
727/// Show and drag the horizontal offset of overflowing content.
728///
729/// ## Parameters
730///
731/// - `total` — content extent in physical pixels; defaults to controller
732///   content.
733/// - `visible` — viewport extent in physical pixels; defaults to controller
734///   viewport.
735/// - `offset` — current scroll offset; defaults to the controller position.
736/// - `thickness` — thickness of the scrollbar track.
737/// - `state` — scroll controller the scrollbar reads and drives.
738/// - `scrollbar_behavior` — visibility behavior (always, auto-hide, hidden).
739/// - `track_color`, `thumb_color`, `thumb_hover_color` — themed colors.
740/// - `scrollbar_state` — shared interaction state; created internally when
741///   absent.
742///
743/// Wheel input follows the attached viewport. A custom track without a viewport
744/// retains tap, drag and accessibility actions and leaves wheel input to
745/// ancestors.
746///
747/// ## Examples
748///
749/// ```
750/// use tessera_components::scrollable::{ScrollableController, scrollbar_h};
751/// use tessera_components::theme::material_theme;
752/// use tessera_ui::{Px, PxPosition, remember, tessera};
753/// #[tessera]
754/// fn tracked_content() {
755///     material_theme().child(|| {
756///         let controller = remember(ScrollableController::new);
757///         let offset = PxPosition::new(Px(-40), Px::ZERO);
758///         controller.with_mut(|controller| controller.set_scroll_position(offset));
759///         assert_eq!(
760///             controller.with(|controller| controller.child_position()),
761///             offset
762///         );
763///         scrollbar_h()
764///             .state(controller)
765///             .total(Px(600))
766///             .visible(Px(200));
767///     });
768/// }
769/// # tessera_ui::testing::layout_test(tracked_content).run();
770/// ```
771#[tessera]
772pub fn scrollbar_h(
773    total: Option<Px>,
774    visible: Option<Px>,
775    offset: Option<Px>,
776    thickness: Option<Dp>,
777    state: Option<State<ScrollableController>>,
778    scrollbar_behavior: Option<ScrollBarBehavior>,
779    track_color: Option<Color>,
780    thumb_color: Option<Color>,
781    thumb_hover_color: Option<Color>,
782    scrollbar_state: Option<ScrollBarState>,
783) {
784    scrollbar(
785        ScrollOrientation::Horizontal,
786        state.expect("scrollbar_h requires state"),
787    )
788    .total_optional(total)
789    .visible_optional(visible)
790    .offset_optional(offset)
791    .thickness_optional(thickness)
792    .scrollbar_behavior_optional(scrollbar_behavior)
793    .track_color_optional(track_color)
794    .thumb_color_optional(thumb_color)
795    .thumb_hover_color_optional(thumb_hover_color)
796    .scrollbar_state_optional(scrollbar_state);
797}
798
799#[tessera]
800fn scrollbar(
801    orientation: ScrollOrientation,
802    state: State<ScrollableController>,
803    total: Option<Px>,
804    visible: Option<Px>,
805    offset: Option<Px>,
806    thickness: Option<Dp>,
807    scrollbar_behavior: Option<ScrollBarBehavior>,
808    track_color: Option<Color>,
809    thumb_color: Option<Color>,
810    thumb_hover_color: Option<Color>,
811    scrollbar_state: Option<ScrollBarState>,
812) {
813    let total = total
814        .unwrap_or_else(|| state.with(|controller| orientation.extent(controller.child_size())));
815    let visible = visible
816        .unwrap_or_else(|| state.with(|controller| orientation.extent(controller.visible_size())));
817    let offset = offset.unwrap_or_else(|| {
818        state.with(|controller| orientation.axis_px(controller.child_position()))
819    });
820    let thickness = thickness.unwrap_or(ScrollbarDefaults::THICKNESS);
821    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
822    let scheme = use_context::<MaterialTheme>()
823        .map(|theme| theme.get().color_scheme)
824        .unwrap_or_default();
825    let track_color = track_color.unwrap_or(Color::TRANSPARENT);
826    let thumb_color = thumb_color.unwrap_or_else(|| ScrollbarDefaults::thumb_color(&scheme));
827    let thumb_hover_color =
828        thumb_hover_color.unwrap_or_else(|| ScrollbarDefaults::thumb_hover_color(&scheme));
829    let args = ScrollBarConfig {
830        total,
831        visible,
832        offset,
833        thickness,
834        state,
835        scrollbar_behavior,
836        track_color,
837        thumb_color,
838        thumb_hover_color,
839        scrollbar_state,
840    };
841    let state = if let Some(state) = args.scrollbar_state {
842        state
843    } else {
844        ScrollBarState::new(remember(ScrollBarStateInner::default))
845    };
846    let frame_time = remember(current_frame_nanos);
847    let _ = frame_time.get();
848    let tap_recognizer = remember(TapRecognizer::default);
849    let drag_recognizer = remember(DragRecognizer::default);
850    let scroll_recognizer = remember(ScrollRecognizer::default);
851    let nested_scroll_connection = NestedScrollConnection::nearest();
852    let frame_nanos = current_frame_nanos();
853
854    handle_autohide_if_needed(&args, &state, frame_nanos);
855    if needs_scrollbar_frame_tick(&args, &state, frame_nanos) {
856        let args = args.clone();
857        let state = state;
858        receive_frame_nanos(move |frame_nanos| {
859            frame_time.set(frame_nanos);
860            if needs_scrollbar_frame_tick(&args, &state, frame_nanos) {
861                tessera_ui::FrameNanosControl::Continue
862            } else {
863                tessera_ui::FrameNanosControl::Stop
864            }
865        });
866    }
867
868    let should_show = should_show_scrollbar(&args, &state);
869
870    if !should_show {
871        layout().policy(ZeroLayout);
872        return;
873    }
874
875    if args.visible <= Px::ZERO || args.total <= Px::ZERO || args.thickness <= Dp::ZERO {
876        layout().policy(ZeroLayout);
877        return;
878    }
879
880    let thickness = args.thickness.to_px();
881    let track_extent = args.visible;
882    let thumb_extent = compute_thumb_size(args.visible, args.total);
883    let has_overflow = args.total > args.visible;
884    let track_color = if has_overflow {
885        args.track_color
886    } else {
887        args.track_color.with_alpha(0.0)
888    };
889
890    let handler_args = args.clone();
891    let handler_state = state;
892    let modifier = with_pointer_input(Modifier::new(), move |mut input| {
893        let frame_nanos = current_frame_nanos();
894        handle_state(
895            orientation,
896            &handler_args,
897            &handler_state,
898            ScrollBarTrackMetrics {
899                track_extent: track_extent,
900                thumb_extent: thumb_extent,
901            },
902            tap_recognizer,
903            drag_recognizer,
904            &mut input,
905            frame_nanos,
906        );
907    })
908    .push_pointer_input(ScrollbarWheelModifierNode {
909        controller: args.state,
910        recognizer: scroll_recognizer,
911        connection: nested_scroll_connection,
912        scrollbar_state: state,
913        behavior: args.scrollbar_behavior.clone(),
914    })
915    .push_semantics(ScrollbarSemanticsModifierNode {
916        args: args.clone(),
917        state: state,
918        orientation,
919    });
920
921    let thumb_color = if has_overflow {
922        compute_thumb_color(
923            &state.read(),
924            args.thumb_color,
925            args.thumb_hover_color,
926            frame_nanos,
927        )
928    } else {
929        args.thumb_color.with_alpha(0.0)
930    };
931    let progress = compute_thumb_progress(args.offset, args.total);
932    let thumb_offset = args.visible.to_f32() * progress;
933
934    layout()
935        .modifier(modifier)
936        .policy(ScrollBarLayout {
937            thumb_offset: orientation.position(Px::from_f32(thumb_offset), Px::ZERO),
938        })
939        .child(move || {
940            for (extent, color) in [(track_extent, track_color), (thumb_extent, thumb_color)] {
941                let size = orientation.size(extent, thickness);
942                surface()
943                    .modifier(Modifier::new().constrain(
944                        Some(AxisConstraint::exact(size.width)),
945                        Some(AxisConstraint::exact(size.height)),
946                    ))
947                    .style(color.into())
948                    .shape(Shape::CAPSULE)
949                    .child(|| {});
950            }
951        });
952}
953
954#[cfg(test)]
955mod tests {
956    use tessera_ui::Color;
957
958    use super::{HOVER_FADE_DURATION_SECS, ScrollBarStateInner, compute_thumb_color};
959
960    #[test]
961    fn thumb_color_interpolates_towards_the_hover_color() {
962        let thumb = Color::new(1.0, 0.0, 0.0, 0.4);
963        let hovered = Color::new(1.0, 0.0, 0.0, 0.6);
964        let mut state = ScrollBarStateInner::default();
965
966        assert_eq!(compute_thumb_color(&state, thumb, hovered, 0), thumb);
967
968        {
969            state.is_hovered = true;
970            state.hover_start_frame_nanos = Some(0);
971        }
972        let fade_nanos = (HOVER_FADE_DURATION_SECS * 1_000_000_000.0) as u64;
973
974        assert_eq!(compute_thumb_color(&state, thumb, hovered, 0), thumb);
975
976        let faded = compute_thumb_color(&state, thumb, hovered, fade_nanos);
977        assert!((faded.a - hovered.a).abs() < 1e-5);
978        assert!((faded.a - thumb.a).abs() > 1e-3);
979    }
980}
981
982#[cfg(all(test, not(target_family = "wasm")))]
983#[path = "scrollbar_bindings_tests.rs"]
984mod bindings_tests;