1use 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
27pub struct ScrollbarDefaults;
37
38impl ScrollbarDefaults {
39 pub const THICKNESS: Dp = Dp(8.0);
41
42 pub const THUMB_ALPHA: f32 = 0.5;
44
45 pub const THUMB_HOVER_ALPHA: f32 = 0.7;
47
48 pub fn thumb_color(scheme: &MaterialColorScheme) -> Color {
50 scheme.on_surface.with_alpha(Self::THUMB_ALPHA)
51 }
52
53 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)]
181pub struct ScrollBarStateInner {
183 pub is_dragging: bool,
185 pub pressed_on_thumb: bool,
187 pub is_hovered: bool,
189 pub hover_start_frame_nanos: Option<u64>,
191 pub last_scroll_activity_frame_nanos: Option<u64>,
194 pub should_be_visible: bool,
196}
197
198#[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 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 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 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 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 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 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 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 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 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#[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#[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;