Skip to main content

frust_widgets/
scrollbar.rs

1//! The baseline scrollbar: an opt-in overlay indicator for the vertical
2//! [`ScrollView`](crate::ScrollView) and [`ListView`](crate::ListView).
3//!
4//! A surface paints no bar unless the view calls `.scrollbar(ScrollbarStyle)`;
5//! without it nothing here runs, so an existing app sees no paint, hit-test or
6//! controller change. With one, the bar paints in a trailing lane on top of the
7//! content: the thumb is as long a share of the track as the viewport is of the
8//! content (never shorter than [`ScrollbarStyle::min_thumb`]), it widens to
9//! [`ScrollbarStyle::hover_thickness`] while the pointer is over the lane or the
10//! thumb is held, and [`ScrollbarShow`] picks when it is visible. A primary press
11//! on the thumb captures the pointer and drags it, and a press elsewhere on the
12//! track pages by one viewport extent; both write the offset through the
13//! surface's [`ScrollController`](crate::ScrollController) `jump_to` — the app's
14//! handle when one is attached, otherwise an internal one the surface creates
15//! only while a bar is enabled. A thumb press first jumps to where the surface
16//! stands, which halts any fling or tween under it.
17//!
18//! The fade runs at paint off [`PaintCtx::frame_time`]: the bar holds for
19//! [`ScrollbarStyle::fade_delay_ms`] after the last scroll, hover or press, then
20//! fades over [`ScrollbarStyle::fade_duration_ms`]; `motion.reduce_motion` snaps
21//! it out instead. The state machine lives here and the surfaces reach it
22//! through `scroll_core.rs`. Horizontal surfaces and `CustomScrollView` take no
23//! scrollbar.
24
25use std::time::Duration;
26
27use frust_core::{EventCtx, FrameTime, PaintCtx, PaintScene, PointerEvent, PointerPhase};
28use frust_theme::Theme;
29use kurbo::{Point, Rect, Size};
30use peniko::Color;
31
32use crate::authoring::presses;
33use crate::scroll::ScrollInfo;
34
35/// Resting bar thickness, logical px. **Community-approximate**, like the
36/// other fallbacks below: overlay scrollbars sit in this range on every
37/// platform and no theme token covers them.
38const THICKNESS: f64 = 6.0;
39/// Thickness while hovered or held.
40const HOVER_THICKNESS: f64 = 10.0;
41/// Shortest thumb painted, so a long document keeps a grabbable thumb.
42const MIN_THUMB: f64 = 24.0;
43/// Gap between the bar and the viewport's trailing, top and bottom edges.
44const INSET: f64 = 2.0;
45/// Idle hold before the fade starts.
46const FADE_DELAY_MS: f64 = 800.0;
47/// Fade-out length.
48const FADE_DURATION_MS: f64 = 250.0;
49/// Unthemed thumb/track ink; a theme supplies `on_surface_variant` instead.
50const FALLBACK_INK: Color = Color::from_rgb8(0x5C, 0x5C, 0x5C);
51/// Thumb alpha at rest, and while widened.
52const THUMB_ALPHA: f32 = 0.5;
53const THUMB_ACTIVE_ALPHA: f32 = 0.75;
54/// Track alpha, painted only while widened.
55const TRACK_ALPHA: f32 = 0.12;
56
57/// When an enabled scrollbar is visible.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum ScrollbarShow {
60    /// Never painted and never hit-tested — the same as no `.scrollbar(..)`.
61    Never,
62    /// Shown while the surface scrolls (or the lane is hovered or the thumb
63    /// held), fading out once idle. The default.
64    #[default]
65    WhileScrolling,
66    /// Shown while the pointer is over the surface, and while scrolling.
67    Hover,
68    /// Shown whenever the content overflows.
69    Always,
70}
71
72/// Metrics and visibility of an opt-in scrollbar, handed to
73/// [`ScrollView::scrollbar`](crate::ScrollView::scrollbar) or
74/// [`ListView::scrollbar`](crate::ListView::scrollbar) (vertical only; no
75/// horizontal or `CustomScrollView` surface takes one). Colors come from the
76/// theme (`on_surface_variant`), with an unthemed fallback.
77///
78/// ```
79/// use frust_widgets::{ScrollView, ScrollbarShow, ScrollbarStyle, scroll_view, text};
80/// let style = ScrollbarStyle::default().show(ScrollbarShow::Always).thickness(4.0);
81/// let view: ScrollView<()> = scroll_view(text("hi")).scrollbar(style);
82/// # let _ = view;
83/// ```
84#[derive(Clone, Copy, Debug, PartialEq)]
85pub struct ScrollbarStyle {
86    /// Resting bar thickness, logical px.
87    pub thickness: f64,
88    /// Thickness while the lane is hovered or the thumb is held.
89    pub hover_thickness: f64,
90    /// Shortest thumb painted, clamped to the track length.
91    pub min_thumb: f64,
92    /// Gap between the bar and the viewport's trailing, top and bottom edges.
93    pub inset: f64,
94    /// Idle hold before the fade starts, in ms.
95    pub fade_delay_ms: f64,
96    /// Fade-out length, in ms; `reduce_motion` or `0` snaps instead.
97    pub fade_duration_ms: f64,
98    /// When the bar is visible.
99    pub show: ScrollbarShow,
100}
101
102impl Default for ScrollbarStyle {
103    fn default() -> Self {
104        Self {
105            thickness: THICKNESS,
106            hover_thickness: HOVER_THICKNESS,
107            min_thumb: MIN_THUMB,
108            inset: INSET,
109            fade_delay_ms: FADE_DELAY_MS,
110            fade_duration_ms: FADE_DURATION_MS,
111            show: ScrollbarShow::default(),
112        }
113    }
114}
115
116impl ScrollbarStyle {
117    /// The default style with `show` as its visibility rule.
118    pub fn new(show: ScrollbarShow) -> Self {
119        Self::default().show(show)
120    }
121
122    /// Set [`ScrollbarStyle::show`].
123    pub fn show(mut self, show: ScrollbarShow) -> Self {
124        self.show = show;
125        self
126    }
127
128    /// Set [`ScrollbarStyle::thickness`].
129    pub fn thickness(mut self, px: f64) -> Self {
130        self.thickness = px;
131        self
132    }
133
134    /// Set [`ScrollbarStyle::hover_thickness`].
135    pub fn hover_thickness(mut self, px: f64) -> Self {
136        self.hover_thickness = px;
137        self
138    }
139
140    /// Set [`ScrollbarStyle::min_thumb`].
141    pub fn min_thumb(mut self, px: f64) -> Self {
142        self.min_thumb = px;
143        self
144    }
145
146    /// Set [`ScrollbarStyle::inset`].
147    pub fn inset(mut self, px: f64) -> Self {
148        self.inset = px;
149        self
150    }
151
152    /// Set [`ScrollbarStyle::fade_delay_ms`].
153    pub fn fade_delay_ms(mut self, ms: f64) -> Self {
154        self.fade_delay_ms = ms;
155        self
156    }
157
158    /// Set [`ScrollbarStyle::fade_duration_ms`].
159    pub fn fade_duration_ms(mut self, ms: f64) -> Self {
160        self.fade_duration_ms = ms;
161        self
162    }
163}
164
165/// One frame's bar geometry in surface-local coordinates.
166#[derive(Clone, Copy, Debug, PartialEq)]
167pub(crate) struct Scrollbar {
168    /// The full-height hit lane along the trailing edge (widest bar plus insets).
169    pub(crate) lane: Rect,
170    /// The painted track: the viewport extent minus the insets.
171    pub(crate) track: Rect,
172    /// The thumb, inside the track.
173    pub(crate) thumb: Rect,
174    /// How far the thumb's top can slide.
175    travel: f64,
176    max_offset: f64,
177}
178
179impl Scrollbar {
180    /// The bar for a `size` viewport at `info`, or `None` when nothing
181    /// overflows or no track fits.
182    pub(crate) fn new(
183        style: &ScrollbarStyle,
184        size: Size,
185        info: ScrollInfo,
186        widened: bool,
187    ) -> Option<Self> {
188        let max_offset = info.max_offset;
189        let inset = style.inset.max(0.0);
190        let thickness = if widened {
191            style.hover_thickness
192        } else {
193            style.thickness
194        };
195        let track_len = size.height - 2.0 * inset;
196        if !(max_offset > 0.0 && track_len > 0.0 && thickness > 0.0 && size.width > 0.0) {
197            return None;
198        }
199        let right = size.width - inset;
200        let track = Rect::new(right - thickness, inset, right, inset + track_len);
201        let lane_width = style.hover_thickness.max(style.thickness) + 2.0 * inset;
202        let lane = Rect::new(
203            (size.width - lane_width).max(0.0),
204            0.0,
205            size.width,
206            size.height,
207        );
208        let share = size.height / (size.height + max_offset);
209        let thumb_len = (track_len * share)
210            .max(style.min_thumb.min(track_len))
211            .min(track_len);
212        let travel = track_len - thumb_len;
213        let progress = (info.offset / max_offset).clamp(0.0, 1.0);
214        let top = inset + travel * progress;
215        let thumb = Rect::new(track.x0, top, track.x1, top + thumb_len);
216        Some(Self {
217            lane,
218            track,
219            thumb,
220            travel,
221            max_offset,
222        })
223    }
224
225    /// The offset a thumb drag `dy` px from a press at `start_offset` maps to,
226    /// at the ratio the thumb is painted at.
227    pub(crate) fn offset_for_drag(&self, start_offset: f64, dy: f64) -> f64 {
228        if self.travel <= 0.0 {
229            return start_offset;
230        }
231        (start_offset + dy * self.max_offset / self.travel).clamp(0.0, self.max_offset)
232    }
233
234    fn hits_lane(&self, p: Point) -> bool {
235        self.lane.contains(p)
236    }
237
238    /// Lane-wide across, the thumb's extent along.
239    fn hits_thumb(&self, p: Point) -> bool {
240        self.hits_lane(p) && p.y >= self.thumb.y0 && p.y < self.thumb.y1
241    }
242}
243
244/// What a pointer event did to the bar, for the core to act on.
245#[derive(Clone, Copy, Debug, PartialEq)]
246pub(crate) enum BarEvent {
247    /// Not the bar's: route it to the gesture machinery.
248    Pass,
249    /// Consumed with nothing to write.
250    Handled,
251    /// Consumed: jump the controller to this offset.
252    JumpTo(f64),
253}
254
255/// A primary press the bar captured.
256#[derive(Clone, Copy, Debug, PartialEq)]
257enum Press {
258    /// Dragging the thumb from a press at `start_y` over `start_offset`.
259    Thumb { start_y: f64, start_offset: f64 },
260    /// A track press that already paged; its moves and release are swallowed.
261    Track,
262}
263
264/// Longest single hold wake: a far-off fade re-arms after this instead of
265/// overflowing `Duration`.
266const MAX_HOLD_SECS: f64 = 3600.0;
267
268/// Frame interval assumed when pacing a fade (the paint ctx carries none).
269const FADE_FRAME_SECS: f64 = 1.0 / 60.0;
270
271/// One 8-bit opacity level.
272const LEVEL: f64 = 1.0 / 255.0;
273
274/// The paint/fade schedule [`ScrollbarState::visibility`] asks for.
275///
276/// `Fade` asks for a frame every tick and is used only while one frame's
277/// opacity step is at least one 8-bit level; a slower fade holds until the
278/// next visible level instead.
279#[derive(Clone, Copy, Debug, PartialEq)]
280enum Wake {
281    Idle,
282    Hold(Duration),
283    Fade,
284}
285
286/// A surface's live scrollbar: its style plus the hover, press and fade state.
287pub(crate) struct ScrollbarState {
288    style: ScrollbarStyle,
289    lane_hovered: bool,
290    surface_hovered: bool,
291    press: Option<Press>,
292    /// The last frame the bar was active (scrolled, hovered or held).
293    last_active: Option<FrameTime>,
294    /// `(offset, overscroll)` at the last paint, to detect motion.
295    last_position: Option<(f64, f64)>,
296    /// An event-pass activity the next paint stamps (events carry no clock).
297    stamp_pending: bool,
298    /// The opacity last painted; a press hits only a visible bar.
299    opacity: f64,
300    /// The wake the last paint asked for (test-only).
301    #[cfg(test)]
302    last_wake: Option<Wake>,
303}
304
305impl ScrollbarState {
306    pub(crate) fn new(style: ScrollbarStyle) -> Self {
307        Self {
308            style,
309            lane_hovered: false,
310            surface_hovered: false,
311            press: None,
312            last_active: None,
313            last_position: None,
314            stamp_pending: false,
315            opacity: 0.0,
316            #[cfg(test)]
317            last_wake: None,
318        }
319    }
320
321    /// Replace the style, returning whether it changed.
322    pub(crate) fn set_style(&mut self, style: ScrollbarStyle) -> bool {
323        let changed = self.style != style;
324        self.style = style;
325        changed
326    }
327
328    pub(crate) fn is_widened(&self) -> bool {
329        self.lane_hovered || self.press.is_some()
330    }
331
332    /// Hovered or held: shown at full opacity with no fade running.
333    fn engaged(&self) -> bool {
334        self.is_widened() || (self.style.show == ScrollbarShow::Hover && self.surface_hovered)
335    }
336
337    /// The opacity the last paint used (test-only read).
338    #[cfg(test)]
339    pub(crate) fn opacity(&self) -> f64 {
340        self.opacity
341    }
342
343    /// The bar's half of a pointer event, ahead of the gesture machinery;
344    /// `can_drive` is whether a current controller binding can take the jump.
345    pub(crate) fn pointer(
346        &mut self,
347        ctx: &mut EventCtx,
348        p: &PointerEvent,
349        info: ScrollInfo,
350        can_drive: bool,
351    ) -> BarEvent {
352        let size = ctx.size();
353        match p.phase {
354            PointerPhase::Down => {
355                self.press = None;
356                if !presses(p) || !can_drive || self.opacity <= 0.0 {
357                    return BarEvent::Pass;
358                }
359                let Some(bar) = Scrollbar::new(&self.style, size, info, true) else {
360                    return BarEvent::Pass;
361                };
362                if !bar.hits_lane(p.position) {
363                    return BarEvent::Pass;
364                }
365                ctx.capture_pointer();
366                ctx.request_redraw();
367                self.stamp_pending = true;
368                if bar.hits_thumb(p.position) {
369                    self.press = Some(Press::Thumb {
370                        start_y: p.position.y,
371                        start_offset: info.offset,
372                    });
373                    return BarEvent::JumpTo(info.offset);
374                }
375                self.press = Some(Press::Track);
376                let page = if p.position.y < bar.thumb.y0 {
377                    -size.height
378                } else {
379                    size.height
380                };
381                BarEvent::JumpTo((info.offset + page).clamp(0.0, info.max_offset))
382            }
383            PointerPhase::Move => match self.press {
384                Some(Press::Thumb {
385                    start_y,
386                    start_offset,
387                }) => {
388                    ctx.request_redraw();
389                    match Scrollbar::new(&self.style, size, info, true) {
390                        Some(bar) => BarEvent::JumpTo(
391                            bar.offset_for_drag(start_offset, p.position.y - start_y),
392                        ),
393                        None => BarEvent::Handled,
394                    }
395                }
396                Some(Press::Track) => BarEvent::Handled,
397                None => BarEvent::Pass,
398            },
399            PointerPhase::Up | PointerPhase::Cancel => {
400                if self.press.take().is_none() {
401                    return BarEvent::Pass;
402                }
403                self.stamp_pending = true;
404                ctx.request_redraw();
405                BarEvent::Handled
406            }
407        }
408    }
409
410    /// Latch hover from an uncaptured `Move` the content has already seen
411    /// (a container claims after its children), redrawing only on a change
412    /// that alters the bar.
413    pub(crate) fn hover(&mut self, ctx: &mut EventCtx, position: Point, info: ScrollInfo) {
414        let size = ctx.size();
415        let in_surface = Rect::from_origin_size(Point::ZERO, size).contains(position);
416        let in_lane = Scrollbar::new(&self.style, size, info, true)
417            .is_some_and(|bar| bar.hits_lane(position));
418        let hover_mode = self.style.show == ScrollbarShow::Hover;
419        if in_lane || (hover_mode && in_surface) {
420            ctx.claim_hover();
421        }
422        let was_engaged = self.engaged();
423        let lane_changed = self.lane_hovered != in_lane;
424        let surface_changed = self.surface_hovered != in_surface;
425        self.lane_hovered = in_lane;
426        self.surface_hovered = in_surface;
427        if was_engaged != self.engaged() {
428            self.stamp_pending = true;
429        }
430        if lane_changed || (hover_mode && surface_changed) {
431            ctx.request_redraw();
432        }
433    }
434
435    /// The opacity at `now` and the frame schedule that keeps the fade moving.
436    ///
437    /// Pacing: a fade whose per-frame step (`1/60 s` over the duration) is
438    /// below one 8-bit level returns `Wake::Hold` until the next visible
439    /// level, capped at `MAX_HOLD_SECS`, rather than `Wake::Fade`.
440    fn visibility(&self, now: FrameTime, reduce_motion: bool) -> (f64, Wake) {
441        match self.style.show {
442            ScrollbarShow::Always => return (1.0, Wake::Idle),
443            ScrollbarShow::Never => return (0.0, Wake::Idle),
444            ScrollbarShow::WhileScrolling | ScrollbarShow::Hover => {}
445        }
446        if self.engaged() {
447            return (1.0, Wake::Idle);
448        }
449        let Some(active) = self.last_active else {
450            return (0.0, Wake::Idle);
451        };
452        let elapsed = now.saturating_sub(active).as_secs_f64() * 1000.0;
453        let delay = self.style.fade_delay_ms.max(0.0);
454        if !delay.is_finite() {
455            return (1.0, Wake::Idle); // never fades: nothing to wake for
456        }
457        if elapsed < delay {
458            let remaining = ((delay - elapsed) / 1000.0).min(MAX_HOLD_SECS);
459            return (1.0, Wake::Hold(Duration::from_secs_f64(remaining)));
460        }
461        let duration = self.style.fade_duration_ms;
462        if reduce_motion || duration.is_nan() || duration <= 0.0 {
463            return (0.0, Wake::Idle);
464        }
465        if !duration.is_finite() {
466            return (1.0, Wake::Idle); // an endless fade stays opaque, no frame loop
467        }
468        let t = (elapsed - delay) / duration;
469        if t >= 1.0 {
470            (0.0, Wake::Idle)
471        } else {
472            let opacity = 1.0 - t;
473            if FADE_FRAME_SECS * 1000.0 / duration >= LEVEL {
474                return (opacity, Wake::Fade);
475            }
476            let levels = opacity * 255.0;
477            let next = levels.ceil() - 1.0;
478            let ms = ((levels - next) / 255.0 * duration).max(1.0);
479            let secs = (ms / 1000.0).min(MAX_HOLD_SECS);
480            (opacity, Wake::Hold(Duration::from_secs_f64(secs)))
481        }
482    }
483
484    /// Advance the fade and paint the bar over the content at `info`.
485    pub(crate) fn paint(
486        &mut self,
487        ctx: &mut PaintCtx,
488        scene: &mut dyn PaintScene,
489        info: ScrollInfo,
490        reduce_motion: bool,
491    ) {
492        let was_engaged = self.engaged();
493        if !ctx.is_hovered() {
494            self.lane_hovered = false;
495            self.surface_hovered = false;
496        }
497        let now = ctx.frame_time();
498        let position = (info.offset, info.overscroll);
499        let moved = self.last_position.is_some_and(|last| last != position);
500        self.last_position = Some(position);
501        let pending = std::mem::take(&mut self.stamp_pending);
502        if moved || pending || was_engaged || self.engaged() {
503            self.last_active = Some(now);
504        }
505        let (opacity, wake) = self.visibility(now, reduce_motion);
506        self.opacity = opacity;
507        #[cfg(test)]
508        {
509            self.last_wake = Some(wake);
510        }
511        match wake {
512            Wake::Idle => {}
513            Wake::Hold(remaining) => ctx.request_frame_paced_at(remaining),
514            Wake::Fade => ctx.request_frame(),
515        }
516        if opacity <= 0.0 {
517            return;
518        }
519        let widened = self.is_widened();
520        let Some(bar) = Scrollbar::new(&self.style, ctx.size(), info, widened) else {
521            return;
522        };
523        let ink =
524            Theme::from_paint_ctx(ctx).map_or(FALLBACK_INK, |t| t.scheme().on_surface_variant);
525        let shift = ctx.origin().to_vec2();
526        let radius = bar.track.width() / 2.0;
527        let opacity = opacity as f32;
528        if widened {
529            let track = with_alpha(ink, TRACK_ALPHA * opacity);
530            scene.fill_rounded_rect(bar.track.origin() + shift, bar.track.size(), radius, track);
531        }
532        let alpha = if widened {
533            THUMB_ACTIVE_ALPHA
534        } else {
535            THUMB_ALPHA
536        };
537        let thumb = with_alpha(ink, alpha * opacity);
538        scene.fill_rounded_rect(bar.thumb.origin() + shift, bar.thumb.size(), radius, thumb);
539    }
540}
541
542/// `color` with its alpha replaced by `alpha` (the crate's per-module helper).
543fn with_alpha(color: Color, alpha: f32) -> Color {
544    let c = color.components;
545    Color::new([c[0], c[1], c[2], alpha])
546}
547
548#[cfg(test)]
549pub(crate) mod tests {
550    use std::any::Any;
551
552    use frust_core::PointerButton;
553
554    use super::*;
555
556    /// A recorder for the bar's rounded rects (the content paints plain rects).
557    #[derive(Default)]
558    pub(crate) struct BarScene {
559        pub(crate) rounded: Vec<(Point, Size, Color)>,
560    }
561
562    impl PaintScene for BarScene {
563        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
564        fn draw_text(&mut self, _origin: Point, _text: &str) {}
565        fn fill_rounded_rect(&mut self, origin: Point, size: Size, _radius: f64, color: Color) {
566            self.rounded.push((origin, size, color));
567        }
568    }
569
570    const VIEW: Size = Size::new(200.0, 100.0);
571
572    fn info(offset: f64, max_offset: f64) -> ScrollInfo {
573        ScrollInfo {
574            offset,
575            max_offset,
576            overscroll: 0.0,
577        }
578    }
579
580    fn at(ms: u64) -> FrameTime {
581        FrameTime::from_nanos(ms * 1_000_000)
582    }
583
584    fn pointer(phase: PointerPhase, x: f64, y: f64) -> PointerEvent {
585        PointerEvent {
586            phase,
587            position: Point::new(x, y),
588            button: PointerButton::Primary,
589        }
590    }
591
592    fn send(state: &mut ScrollbarState, p: PointerEvent, info: ScrollInfo) -> (BarEvent, bool) {
593        let mut unit = ();
594        let any: &mut dyn Any = &mut unit;
595        let mut ctx = EventCtx::new(any, Point::ZERO, VIEW);
596        let result = state.pointer(&mut ctx, &p, info, true);
597        (result, ctx.is_pointer_captured())
598    }
599
600    fn hover(state: &mut ScrollbarState, x: f64, y: f64, info: ScrollInfo) -> bool {
601        let mut unit = ();
602        let any: &mut dyn Any = &mut unit;
603        let mut ctx = EventCtx::new(any, Point::ZERO, VIEW);
604        state.hover(&mut ctx, Point::new(x, y), info);
605        ctx.needs_redraw()
606    }
607
608    /// Paint at `ms`, returning how many rects drew and whether a frame was asked.
609    fn paint(state: &mut ScrollbarState, ms: u64, info: ScrollInfo, reduce: bool) -> (usize, bool) {
610        let mut ctx = PaintCtx::for_test(Point::ZERO, VIEW, at(ms));
611        let mut scene = BarScene::default();
612        state.paint(&mut ctx, &mut scene, info, reduce);
613        (scene.rounded.len(), ctx.needs_frame())
614    }
615
616    #[test]
617    fn the_thumb_stays_in_the_track_and_tracks_progress() {
618        let style = ScrollbarStyle::default();
619        for (offset, max) in [
620            (0.0, 900.0),
621            (450.0, 900.0),
622            (900.0, 900.0),
623            (5000.0, 900.0),
624        ] {
625            let bar = Scrollbar::new(&style, VIEW, info(offset, max), false).unwrap();
626            assert!(bar.thumb.y0 >= bar.track.y0 && bar.thumb.y1 <= bar.track.y1);
627            assert_eq!(bar.track.height(), VIEW.height - 2.0 * INSET);
628            assert_eq!(bar.track.x1, VIEW.width - INSET);
629        }
630        let top = Scrollbar::new(&style, VIEW, info(-50.0, 900.0), false).unwrap();
631        assert_eq!(top.thumb.y0, top.track.y0, "progress clamps at the top");
632        let end = Scrollbar::new(&style, VIEW, info(5000.0, 900.0), false).unwrap();
633        assert_eq!(end.thumb.y1, end.track.y1, "and at the bottom");
634    }
635
636    #[test]
637    fn the_thumb_is_proportional_with_a_minimum() {
638        let style = ScrollbarStyle::default();
639        let half = Scrollbar::new(&style, VIEW, info(0.0, 100.0), false).unwrap();
640        assert_eq!(half.thumb.height(), half.track.height() / 2.0);
641        let long = Scrollbar::new(&style, VIEW, info(0.0, 100_000.0), false).unwrap();
642        assert_eq!(long.thumb.height(), MIN_THUMB);
643        let tiny = Scrollbar::new(&style.min_thumb(500.0), VIEW, info(0.0, 10.0), false).unwrap();
644        assert_eq!(
645            tiny.thumb.height(),
646            tiny.track.height(),
647            "min clamps to the track"
648        );
649        assert!(Scrollbar::new(&style, VIEW, info(0.0, 0.0), false).is_none());
650    }
651
652    #[test]
653    fn widening_thickens_the_bar_without_moving_the_thumb() {
654        let style = ScrollbarStyle::default();
655        let rest = Scrollbar::new(&style, VIEW, info(300.0, 900.0), false).unwrap();
656        let wide = Scrollbar::new(&style, VIEW, info(300.0, 900.0), true).unwrap();
657        assert_eq!(rest.track.width(), THICKNESS);
658        assert_eq!(wide.track.width(), HOVER_THICKNESS);
659        assert_eq!(
660            (rest.thumb.y0, rest.thumb.y1),
661            (wide.thumb.y0, wide.thumb.y1)
662        );
663    }
664
665    #[test]
666    fn a_thumb_drag_maps_travel_to_offset() {
667        let mut state = ScrollbarState::new(ScrollbarStyle::new(ScrollbarShow::Always));
668        paint(&mut state, 0, info(0.0, 900.0), false);
669        let bar = Scrollbar::new(&state.style, VIEW, info(0.0, 900.0), true).unwrap();
670        let (down, captured) = send(
671            &mut state,
672            pointer(PointerPhase::Down, 195.0, 5.0),
673            info(0.0, 900.0),
674        );
675        assert_eq!(
676            down,
677            BarEvent::JumpTo(0.0),
678            "the press halts where it landed"
679        );
680        assert!(captured);
681        let half = bar.travel / 2.0;
682        let (moved, _) = send(
683            &mut state,
684            pointer(PointerPhase::Move, 150.0, 5.0 + half),
685            info(0.0, 900.0),
686        );
687        assert_eq!(moved, BarEvent::JumpTo(450.0));
688        let (past, _) = send(
689            &mut state,
690            pointer(PointerPhase::Move, 150.0, 900.0),
691            info(0.0, 900.0),
692        );
693        assert_eq!(past, BarEvent::JumpTo(900.0), "clamped to the range");
694        let (up, _) = send(
695            &mut state,
696            pointer(PointerPhase::Up, 150.0, 900.0),
697            info(0.0, 900.0),
698        );
699        assert_eq!(up, BarEvent::Handled);
700        let (after, _) = send(
701            &mut state,
702            pointer(PointerPhase::Move, 150.0, 50.0),
703            info(0.0, 900.0),
704        );
705        assert_eq!(after, BarEvent::Pass, "the release ends the drag");
706    }
707
708    #[test]
709    fn a_track_press_pages_by_one_viewport() {
710        let mut state = ScrollbarState::new(ScrollbarStyle::new(ScrollbarShow::Always));
711        let at_mid = info(400.0, 900.0);
712        paint(&mut state, 0, at_mid, false);
713        let (below, _) = send(&mut state, pointer(PointerPhase::Down, 195.0, 98.0), at_mid);
714        assert_eq!(below, BarEvent::JumpTo(400.0 + VIEW.height));
715        let (moved, _) = send(&mut state, pointer(PointerPhase::Move, 195.0, 10.0), at_mid);
716        assert_eq!(moved, BarEvent::Handled, "a track press swallows its moves");
717        send(&mut state, pointer(PointerPhase::Up, 195.0, 10.0), at_mid);
718        let (above, _) = send(&mut state, pointer(PointerPhase::Down, 195.0, 3.0), at_mid);
719        assert_eq!(above, BarEvent::JumpTo(400.0 - VIEW.height));
720    }
721
722    #[test]
723    fn presses_off_the_lane_or_non_primary_pass_through() {
724        let mut state = ScrollbarState::new(ScrollbarStyle::new(ScrollbarShow::Always));
725        paint(&mut state, 0, info(0.0, 900.0), false);
726        let (content, captured) = send(
727            &mut state,
728            pointer(PointerPhase::Down, 50.0, 5.0),
729            info(0.0, 900.0),
730        );
731        assert_eq!(content, BarEvent::Pass);
732        assert!(!captured);
733        let mut secondary = pointer(PointerPhase::Down, 195.0, 5.0);
734        secondary.button = PointerButton::Secondary;
735        assert_eq!(
736            send(&mut state, secondary, info(0.0, 900.0)).0,
737            BarEvent::Pass
738        );
739    }
740
741    #[test]
742    fn hovering_the_lane_widens_and_redraws_only_on_change() {
743        let mut state = ScrollbarState::new(ScrollbarStyle::default());
744        assert!(
745            !hover(&mut state, 50.0, 50.0, info(0.0, 900.0)),
746            "content hover: no redraw"
747        );
748        assert!(hover(&mut state, 195.0, 50.0, info(0.0, 900.0)));
749        assert!(state.is_widened());
750        assert!(
751            !hover(&mut state, 196.0, 52.0, info(0.0, 900.0)),
752            "still in the lane"
753        );
754        assert!(hover(&mut state, 50.0, 50.0, info(0.0, 900.0)));
755        assert!(!state.is_widened());
756    }
757
758    #[test]
759    fn while_scrolling_fades_after_the_delay_under_a_stepped_clock() {
760        let mut state = ScrollbarState::new(ScrollbarStyle::default());
761        assert_eq!(
762            paint(&mut state, 0, info(0.0, 900.0), false),
763            (0, false),
764            "idle: hidden"
765        );
766        assert_eq!(
767            paint(&mut state, 16, info(40.0, 900.0), false),
768            (1, true),
769            "a scroll shows it"
770        );
771        assert_eq!(state.opacity(), 1.0);
772        paint(&mut state, 16 + 700, info(40.0, 900.0), false);
773        assert_eq!(state.opacity(), 1.0, "still holding");
774        paint(&mut state, 16 + 800 + 125, info(40.0, 900.0), false);
775        assert!(
776            (state.opacity() - 0.5).abs() < 1e-9,
777            "halfway through the fade"
778        );
779        assert_eq!(
780            paint(&mut state, 16 + 800 + 250, info(40.0, 900.0), false),
781            (0, false)
782        );
783        assert_eq!(state.opacity(), 0.0);
784    }
785
786    #[test]
787    fn non_finite_fade_values_neither_panic_nor_spin() {
788        let hold = ScrollbarStyle::default().fade_delay_ms(f64::INFINITY);
789        let mut state = ScrollbarState::new(hold);
790        paint(&mut state, 0, info(0.0, 900.0), false);
791        assert_eq!(
792            paint(&mut state, 16, info(40.0, 900.0), false),
793            (1, false),
794            "an infinite delay holds the bar with no wake"
795        );
796        paint(&mut state, 16 + 100_000, info(40.0, 900.0), false);
797        assert_eq!(state.opacity(), 1.0);
798
799        let huge = ScrollbarStyle::default().fade_delay_ms(1e30);
800        let mut state = ScrollbarState::new(huge);
801        paint(&mut state, 0, info(0.0, 900.0), false);
802        let (drawn, _) = paint(&mut state, 16, info(40.0, 900.0), false);
803        assert_eq!(drawn, 1, "a huge delay holds without overflowing the wake");
804        assert_eq!(state.last_wake, Some(Wake::Hold(Duration::from_secs(3600))));
805
806        let forever = ScrollbarStyle::default().fade_duration_ms(f64::INFINITY);
807        let mut state = ScrollbarState::new(forever);
808        paint(&mut state, 0, info(0.0, 900.0), false);
809        paint(&mut state, 16, info(40.0, 900.0), false);
810        assert_eq!(
811            paint(&mut state, 16 + 800 + 500, info(40.0, 900.0), false),
812            (1, false),
813            "an endless fade stays opaque and asks for no frame"
814        );
815        assert_eq!(state.opacity(), 1.0);
816
817        let slow = ScrollbarStyle::default().fade_duration_ms(1e9);
818        let mut state = ScrollbarState::new(slow);
819        paint(&mut state, 0, info(0.0, 900.0), false);
820        paint(&mut state, 16, info(40.0, 900.0), false);
821        paint(&mut state, 16 + 801, info(40.0, 900.0), false);
822        assert!(state.opacity() >= 0.99);
823        assert!(
824            matches!(state.last_wake, Some(Wake::Hold(d)) if d > Duration::ZERO),
825            "a sub-level fade step holds instead of spinning: {:?}",
826            state.last_wake
827        );
828
829        let normal = ScrollbarStyle::default().fade_duration_ms(250.0);
830        let mut state = ScrollbarState::new(normal);
831        paint(&mut state, 0, info(0.0, 900.0), false);
832        paint(&mut state, 16, info(40.0, 900.0), false);
833        paint(&mut state, 16 + 801 + 50, info(40.0, 900.0), false);
834        assert_eq!(state.last_wake, Some(Wake::Fade));
835    }
836
837    #[test]
838    fn reduce_motion_snaps_the_bar_out_after_the_delay() {
839        let mut state = ScrollbarState::new(ScrollbarStyle::default());
840        paint(&mut state, 0, info(0.0, 900.0), true);
841        paint(&mut state, 16, info(40.0, 900.0), true);
842        assert_eq!(state.opacity(), 1.0);
843        assert_eq!(
844            paint(&mut state, 16 + 801, info(40.0, 900.0), true),
845            (0, false)
846        );
847    }
848
849    #[test]
850    fn always_paints_and_a_hidden_bar_takes_no_press() {
851        let mut always = ScrollbarState::new(ScrollbarStyle::new(ScrollbarShow::Always));
852        assert_eq!(paint(&mut always, 0, info(0.0, 900.0), false), (1, false));
853        let mut idle = ScrollbarState::new(ScrollbarStyle::default());
854        paint(&mut idle, 0, info(0.0, 900.0), false);
855        let (result, captured) = send(
856            &mut idle,
857            pointer(PointerPhase::Down, 195.0, 5.0),
858            info(0.0, 900.0),
859        );
860        assert_eq!(result, BarEvent::Pass);
861        assert!(!captured);
862    }
863}