Skip to main content

teksilo_widgets/
scroll_bar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ScrollBar — pointer and keyboard affordance for a [`ScrollArea`](crate::scroll_area::ScrollArea).
5//!
6//! `ScrollBar` reads and writes a shared `Signal<f32>` scroll position and a
7//! `Signal<f32>` viewport/content ratio, both supplied by its owning `ScrollArea`.
8//! Interaction (thumb drag, track click, keyboard Up/Down/Home/End, hover) is
9//! handled here; all painting is delegated to the active [`ScrollBarStyle`] impl so
10//! the look is fully theme-overridable.
11//!
12//! Most applications do not need to construct a `ScrollBar` directly — `ScrollArea`
13//! creates and manages the bars automatically. Use this type when building a custom
14//! scroll host (e.g. the `RichTextEditor` manages its own bars to avoid the
15//! wrap/scrollbar circular dependency).
16//!
17//! ## Reaching the thumb with a finger
18//!
19//! The bar is 8–12 dp wide, and it stays that way at every density: growing it
20//! would move the content beside it, and a scroll bar is chrome. The thumb is
21//! reached instead by the two mechanisms built for exactly this — the node
22//! widens for a coarse pointer through [`Widget::hit_outset`], to the 48 dp
23//! Android reserves for a scrollbar touch target, and the thumb itself is
24//! published through [`Widget::target_regions`] so the target-conformance audit
25//! can see a rectangle that is painted inside one leaf node and would otherwise
26//! be invisible to it. A precise pointer gets no outset at all: a cursor's
27//! hot-spot is exact, and widening its targets steals clicks from the content.
28//!
29//! Because the outset widens the bar *across* the scroll axis, every decision
30//! about whether a press is on the thumb is taken **along the axis only** — a
31//! finger 15 dp inboard of an 8 dp bar is beside the thumb, not past it.
32//!
33//! The minimum thumb length follows the density (24 dp Compact, 44 dp Touch),
34//! so a short thumb on a long document is still something a finger can land on.
35//!
36//! ## Accessibility
37//!
38//! Hidden from AT via `set_hidden()`. Scroll actions (Up/Down/Left/Right) are
39//! advertised on the parent `ScrollView` node, not on the bar, so screen readers
40//! navigate the content region directly without stopping on the thumb.
41//!
42//! ```rust
43//! # use teksilo_widgets::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVariant};
44//! # use teksilo_core::signal::Signal;
45//! let position = Signal::new(0.0_f32);
46//! let max_scroll = Signal::new(500.0_f32);
47//! let viewport_ratio = Signal::new(0.4_f32);
48//! let _bar = ScrollBar::new(
49//!     ScrollBarOrientation::Vertical,
50//!     position,
51//!     max_scroll,
52//!     viewport_ratio,
53//! )
54//! .thickness(8.0)
55//! .variant(ScrollBarVariant::Overlay);
56//! ```
57
58use std::cell::Cell;
59use std::rc::Rc;
60
61use teksilo_canvas::{EdgeInsets, Point, Rect, Size, SizeProposal};
62use teksilo_core::accessibility::AccessNodeBuilder;
63use teksilo_core::color_prop::ColorProp;
64use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
65use teksilo_core::gesture::DragPhase;
66use teksilo_core::partition::TargetRegion;
67use teksilo_core::signal::Signal;
68use teksilo_core::styles::density::dp;
69use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig, SharedScrollBarStyle};
70use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
71use teksilo_core::widget_builder::HandlerSet;
72use teksilo_core::widget_id::WidgetId;
73use teksilo_tokens::{RevealPolicy, TargetRole};
74
75use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
76
77// Re-exports so callers can write `ScrollBar::new(..)` /
78// `.visual(ScrollBarVisual::Overlay)` without a deeper import path. The
79// `ScrollBarVisual` alias preserves the historical name; new code can
80// use `ScrollBarVariant` directly.
81pub use teksilo_core::styles::ScrollBarOrientation;
82pub use teksilo_core::styles::ScrollBarVariant;
83pub use teksilo_core::styles::ScrollBarVariant as ScrollBarVisual;
84
85/// The width a scroll bar's thumb must be reachable across for a finger.
86///
87/// Android's `ViewConfiguration.MIN_SCROLLBAR_TOUCH_TARGET` — the bar keeps its
88/// 8–12 dp paint at every density and reaches this through
89/// [`Widget::hit_outset`], which moves nothing and repaints nothing.
90pub const SCROLLBAR_COARSE_TARGET: f32 = 48.0;
91
92/// The shipped minimum thumb length, at Compact. Raised to the density's
93/// `target_size` (44 dp at Touch) at build time; a
94/// [`min_thumb_length`](ScrollBar::min_thumb_length) override wins over both.
95pub const SCROLLBAR_MIN_THUMB_LENGTH: f32 = 24.0;
96
97/// Which part of the bar a [`TargetRegion`] describes.
98///
99/// Reported so an audit — and a router routing a coarse press — can tell the
100/// grab affordance from the paging surface around it.
101pub const SCROLLBAR_PART_THUMB: u16 = 0;
102/// The track either side of the thumb: a tap there pages.
103pub const SCROLLBAR_PART_TRACK: u16 = 1;
104
105/// A scroll bar that shares reactive scroll-position state with a [`ScrollArea`](crate::scroll_area::ScrollArea).
106///
107/// Supports thumb drag, track-click page scroll, and keyboard
108/// Up/Down/Left/Right/Home/End navigation. Hidden from AT — see module docs.
109pub struct ScrollBar {
110    orientation: ScrollBarOrientation,
111    /// Scroll position: 0.0 = start, max_scroll = end.
112    /// Shared with ScrollArea — both read and write.
113    scroll_position: Signal<f32>,
114    /// Maximum scroll value (content_size - viewport_size).
115    /// Written by the ScrollArea, read by the ScrollBar.
116    max_scroll: Signal<f32>,
117    /// Viewport / content ratio (0.0..1.0). Determines thumb size.
118    /// Written by the ScrollArea, read by the ScrollBar.
119    viewport_ratio: Signal<f32>,
120
121    // --- interaction state ---
122    /// Whether the pointer is over the scroll bar.
123    hovered: Signal<bool>,
124    /// Whether the thumb is being dragged.
125    dragging: Signal<bool>,
126    /// Pointer position at drag start (in scroll bar local coords).
127    drag_start_pointer: Rc<Cell<f32>>,
128    /// Scroll position at drag start.
129    drag_start_scroll: Rc<Cell<f32>>,
130    /// Current bounds, cached from last layout for event handling.
131    cached_bounds: Rc<Cell<Rect>>,
132    /// Layout direction captured at `place_children`, so the thumb geometry —
133    /// computed in `build()`'s pointer closures, which have no `PaintContext` —
134    /// can mirror a horizontal bar without every caller threading it.
135    cached_rtl: Rc<Cell<bool>>,
136    /// Body subtree id returned by the active style — kept in
137    /// `children()` so layout traverses through it.
138    body_id: Option<WidgetId>,
139
140    // --- visual tuning ---
141    /// Thickness of the scroll bar (width for vertical, height for horizontal).
142    thickness: f32,
143    /// Minimum thumb length in pixels, or `None` to follow the density.
144    min_thumb_length: Option<f32>,
145    /// The density floor [`Self::min_thumb_length`] falls back to, resolved at
146    /// the last `build`. Shared with the event closures and read by the
147    /// geometry helpers, which run outside a build and have no tokens in scope.
148    /// An explicit floor does not go through it — it is known from the moment
149    /// it is set, so the geometry is right before the first build too.
150    resolved_min_thumb_length: Rc<Cell<f32>>,
151    /// Raised from outside — by a `ScrollArea` while a finger's pan is in
152    /// flight, and by a density whose `RevealPolicy` is `Always` — to show an
153    /// overlay bar that hover alone would keep hidden.
154    revealed: Signal<bool>,
155    /// Pixels to scroll per keyboard step.
156    step_size: f32,
157    /// Visual variant: Permanent / Overlay / Thin.
158    variant: ScrollBarVariant,
159    /// Per-call style override.
160    style_override: Option<SharedScrollBarStyle>,
161    /// Optional thumb tint. `None` → the style paints from the theme's
162    /// `scrollbar_thumb*` tokens; `Some` → tint from this `ColorProp`
163    /// (resolved at paint, so a role / `Signal` stays reactive). See
164    /// [`Self::thumb_color`].
165    thumb_color: Option<ColorProp>,
166}
167
168impl std::fmt::Debug for ScrollBar {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("ScrollBar")
171            .field("orientation", &self.orientation)
172            .field("hovered", &self.hovered.get())
173            .field("dragging", &self.dragging.get())
174            .field("variant", &self.variant)
175            .finish()
176    }
177}
178
179impl ScrollBar {
180    /// Create a new ScrollBar with shared state.
181    ///
182    /// - `scroll_position`: shared `Signal<f32>` for current scroll offset
183    /// - `max_scroll`: shared `Signal<f32>` for maximum scroll offset
184    /// - `viewport_ratio`: shared `Signal<f32>` for viewport/content ratio (0.0..1.0)
185    pub fn new(
186        orientation: ScrollBarOrientation,
187        scroll_position: Signal<f32>,
188        max_scroll: Signal<f32>,
189        viewport_ratio: Signal<f32>,
190    ) -> Self {
191        // Defaults sourced from `ScrollBarStyle` (Int UI: 8 dp on hover,
192        // 4 dp at idle, 24 dp minimum thumb length).
193        Self {
194            orientation,
195            scroll_position,
196            max_scroll,
197            viewport_ratio,
198            hovered: Signal::new(false),
199            dragging: Signal::new(false),
200            drag_start_pointer: Rc::new(Cell::new(0.0)),
201            drag_start_scroll: Rc::new(Cell::new(0.0)),
202            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
203            cached_rtl: Rc::new(Cell::new(false)),
204            body_id: None,
205            thickness: 8.0,
206            min_thumb_length: None,
207            resolved_min_thumb_length: Rc::new(Cell::new(SCROLLBAR_MIN_THUMB_LENGTH)),
208            revealed: Signal::new(false),
209            step_size: 40.0,
210            variant: ScrollBarVariant::default(),
211            style_override: None,
212            thumb_color: None,
213        }
214    }
215
216    /// Set the bar thickness (width for vertical, height for horizontal).
217    pub fn thickness(mut self, thickness: f32) -> Self {
218        self.thickness = thickness;
219        self
220    }
221
222    /// Set the minimum thumb length in pixels, overriding the density.
223    ///
224    /// Left unset the floor is [`SCROLLBAR_MIN_THUMB_LENGTH`] raised to the
225    /// density's target size — 24 dp at Compact, 44 dp at Touch — so a short
226    /// thumb on a long document stays something a finger can land on.
227    pub fn min_thumb_length(mut self, len: f32) -> Self {
228        self.min_thumb_length = Some(len);
229        self
230    }
231
232    /// Show the bar for as long as `revealed` is true, whatever hover says.
233    ///
234    /// An overlay bar is normally revealed by pointer proximity, which a
235    /// contact never produces. `ScrollArea` raises this while a finger's pan is
236    /// in flight; a density whose [`RevealPolicy`]
237    /// is `Always` seeds it true at build. It only ever adds a reveal — nothing
238    /// here can hide a bar that hover has shown.
239    pub fn reveal(mut self, revealed: Signal<bool>) -> Self {
240        self.revealed = revealed;
241        self
242    }
243
244    /// Set the scroll step for keyboard navigation.
245    pub fn step_size(mut self, step: f32) -> Self {
246        self.step_size = step;
247        self
248    }
249
250    /// Set the visual variant. The active [`ScrollBarStyle`] picks how
251    /// to paint each variant; the IntUI default ships Permanent /
252    /// Overlay / Thin out of the box.
253    pub fn visual(mut self, variant: ScrollBarVariant) -> Self {
254        self.variant = variant;
255        self
256    }
257
258    /// Alias for `visual` using the new variant naming.
259    pub fn variant(mut self, variant: ScrollBarVariant) -> Self {
260        self.variant = variant;
261        self
262    }
263
264    /// Override the active [`ScrollBarStyle`] for this widget instance only.
265    pub fn style(mut self, style: impl ScrollBarStyle) -> Self {
266        self.style_override = Some(Rc::new(style));
267        self
268    }
269
270    /// Tint the thumb with an explicit colour instead of the theme's
271    /// `scrollbar_thumb*` tokens. Accepts anything `impl Into<ColorProp>` —
272    /// a `Color`, a theme role (`TextRole`/`SurfaceRole`/…), or a `Signal`;
273    /// resolved against the live theme at paint, so roles and signals stay
274    /// reactive. The active [`ScrollBarStyle`] derives the idle/hover/pressed
275    /// states from this tint. Use when the bar sits on a surface the
276    /// surface-relative tokens don't suit — a tooltip's inverse chip, a
277    /// branded panel. Mirrors [`Button::text_role`](crate::button::Button::text_role).
278    pub fn thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
279        self.thumb_color = Some(color.into());
280        self
281    }
282
283    // --- geometry helpers (kept on the parent because event handlers
284    // need them; the style body re-derives the same numbers from cfg).
285
286    /// The total length of the track (along the scroll axis).
287    fn track_length(&self) -> f32 {
288        let bounds = self.cached_bounds.get();
289        match self.orientation {
290            ScrollBarOrientation::Vertical => bounds.height,
291            ScrollBarOrientation::Horizontal => bounds.width,
292        }
293    }
294
295    /// The floor the thumb may not be shorter than: the caller's, else the one
296    /// the last build resolved from the density.
297    fn min_thumb(&self) -> f32 {
298        self.min_thumb_length
299            .unwrap_or_else(|| self.resolved_min_thumb_length.get())
300    }
301
302    /// Computed thumb length based on viewport ratio.
303    fn thumb_length(&self) -> f32 {
304        let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
305        let track = self.track_length();
306        (track * ratio).max(self.min_thumb()).min(track)
307    }
308
309    /// Thumb offset from the start of the track.
310    fn thumb_offset(&self) -> f32 {
311        let max = self.max_scroll.get();
312        if max <= 0.0 {
313            return 0.0;
314        }
315        let pos = self.scroll_position.get();
316        let ratio = (pos / max).clamp(0.0, 1.0);
317        let available = self.track_length() - self.thumb_length();
318        ratio * available
319    }
320
321    /// The thumb rect in absolute coordinates — the rectangle the active style
322    /// actually paints, derived from the same three numbers the painters read.
323    fn thumb_rect(&self) -> Rect {
324        let bounds = self.cached_bounds.get();
325        let offset = self.thumb_offset();
326        let thumb_len = self.thumb_length();
327        match self.orientation {
328            ScrollBarOrientation::Vertical => {
329                Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
330            }
331            ScrollBarOrientation::Horizontal => {
332                Rect::new(bounds.x + offset, bounds.y, thumb_len, bounds.height)
333            }
334        }
335    }
336}
337
338impl Widget for ScrollBar {
339    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
340        // Resolve the active style: per-call override > theme slot >
341        // built-in `RecipeScrollBarStyle` default.
342        let style: SharedScrollBarStyle = self
343            .style_override
344            .clone()
345            .or_else(|| ctx.theme().style_slots.scroll_bar.clone())
346            .unwrap_or_else(|| {
347                Rc::new(crate::styles::RecipeScrollBarStyle::for_tokens(
348                    &ctx.theme().input,
349                ))
350            });
351
352        // The minimum thumb length follows the density unless the caller named
353        // one. 24 dp at Compact is exactly today's constant, so `dp` here
354        // raises nothing at the density CI runs at — the P20 rule that a
355        // dimension may go through `dp` only when its Compact value already
356        // clears the conformance floor, which 24 dp does by being it.
357        let min_thumb_length = self.min_thumb_length.unwrap_or_else(|| {
358            dp(
359                SCROLLBAR_MIN_THUMB_LENGTH,
360                TargetRole::Target,
361                &ctx.theme().input,
362            )
363        });
364        self.resolved_min_thumb_length.set(min_thumb_length);
365
366        // A density that reveals every affordance (Touch) shows the bar without
367        // being asked; `ScrollArea` raises the same signal while a pan runs.
368        if ctx.theme().input.reveal == RevealPolicy::Always && !self.revealed.get() {
369            self.revealed.set(true);
370        }
371
372        // Derived `scroll_ratio = scroll_position / max_scroll` (clamped
373        // to 0..1). Re-renders the body on every scroll.
374        let scroll_ratio = self
375            .scroll_position
376            .zip(&self.max_scroll)
377            .map(|(pos, max)| {
378                if *max <= 0.0 {
379                    0.0
380                } else {
381                    (*pos / *max).clamp(0.0, 1.0)
382                }
383            });
384        // `is_idle = max_scroll == 0` — body paints nothing in this case.
385        let is_idle = self.max_scroll.map(|m| *m <= 0.0);
386
387        let cfg = ScrollBarStyleConfig {
388            scroll_ratio,
389            viewport_ratio: self.viewport_ratio.clone(),
390            // An external reveal reads as hover to the style: it is the same
391            // question ("is this bar being attended to?") asked by a mechanism
392            // a contact can answer.
393            is_hovered: self.hovered.or(&self.revealed),
394            is_dragging: self.dragging.clone(),
395            is_idle,
396            orientation: self.orientation,
397            variant: self.variant,
398            min_thumb_length,
399            thumb_color: self.thumb_color.clone(),
400        };
401        let body_id = style.make_body(&cfg, ctx);
402        self.body_id = Some(body_id);
403
404        let orientation = self.orientation;
405        let scroll_position = self.scroll_position.clone();
406        let max_scroll = self.max_scroll.clone();
407        let viewport_ratio = self.viewport_ratio.clone();
408        let hovered = self.hovered.clone();
409        let dragging = self.dragging.clone();
410        let drag_start_pointer = self.drag_start_pointer.clone();
411        let drag_start_scroll = self.drag_start_scroll.clone();
412        let cached_bounds = self.cached_bounds.clone();
413        let step_size = self.step_size;
414
415        // A horizontal bar mirrors in a right-to-left window, because
416        // `ScrollArea` already anchors its content to the right and grows
417        // `scroll_x` leftward. The thumb rect, the drag delta and the
418        // track-click direction all key off this one answer, so they cannot
419        // disagree. Vertical bars never mirror.
420        let mirrored = {
421            let cached_rtl = self.cached_rtl.clone();
422            move || -> bool {
423                matches!(orientation, ScrollBarOrientation::Horizontal) && cached_rtl.get()
424            }
425        };
426
427        let axis_value = move |point: Point| -> f32 {
428            match orientation {
429                ScrollBarOrientation::Vertical => point.y,
430                ScrollBarOrientation::Horizontal => point.x,
431            }
432        };
433
434        let set_scroll = {
435            let scroll_position = scroll_position.clone();
436            let max_scroll = max_scroll.clone();
437            move |value: f32| {
438                let max = max_scroll.get();
439                scroll_position.set(value.clamp(0.0, max));
440            }
441        };
442
443        let track_length = {
444            let cached_bounds = cached_bounds.clone();
445            move || -> f32 {
446                let bounds = cached_bounds.get();
447                match orientation {
448                    ScrollBarOrientation::Vertical => bounds.height,
449                    ScrollBarOrientation::Horizontal => bounds.width,
450                }
451            }
452        };
453
454        let thumb_length = {
455            let viewport_ratio = viewport_ratio.clone();
456            let track_length = track_length.clone();
457            move || -> f32 {
458                let ratio = viewport_ratio.get().clamp(0.0, 1.0);
459                let track = track_length();
460                (track * ratio).max(min_thumb_length).min(track)
461            }
462        };
463
464        let thumb_rect = {
465            let cached_bounds = cached_bounds.clone();
466            let scroll_position = scroll_position.clone();
467            let max_scroll = max_scroll.clone();
468            let track_length = track_length.clone();
469            let thumb_length = thumb_length.clone();
470            let mirrored = mirrored.clone();
471            move || -> Rect {
472                let bounds = cached_bounds.get();
473                let max = max_scroll.get();
474                let offset = if max <= 0.0 {
475                    0.0
476                } else {
477                    let pos = scroll_position.get();
478                    let ratio = (pos / max).clamp(0.0, 1.0);
479                    let available = track_length() - thumb_length();
480                    ratio * available
481                };
482                let tl = thumb_length();
483                // Widget-local thumb rect (origin at the scrollbar's own
484                // top-left): event positions arrive widget-local, so the
485                // cross-axis origin is 0, not `bounds.x` / `bounds.y`.
486                match orientation {
487                    ScrollBarOrientation::Vertical => Rect::new(0.0, offset, bounds.width, tl),
488                    ScrollBarOrientation::Horizontal => {
489                        let x = if mirrored() {
490                            bounds.width - offset - tl
491                        } else {
492                            offset
493                        };
494                        Rect::new(x, 0.0, tl, bounds.height)
495                    }
496                }
497            }
498        };
499
500        // Is this press on the thumb? Asked **along the scroll axis only**,
501        // because `hit_outset` widens the bar across that axis for a coarse
502        // pointer: a finger 15 dp inboard of an 8 dp bar is beside the thumb,
503        // and treating it as a miss would page the view out from under it.
504        let on_thumb = {
505            let thumb_rect = thumb_rect.clone();
506            move |position: Point| -> bool {
507                let tr = thumb_rect();
508                let v = axis_value(position);
509                match orientation {
510                    ScrollBarOrientation::Vertical => v >= tr.y && v <= tr.bottom(),
511                    ScrollBarOrientation::Horizontal => v >= tr.x && v <= tr.right(),
512                }
513            }
514        };
515
516        // Scrollbars are pointer affordances. AT scrolls through the parent
517        // ScrollView node's ScrollUp/Down/Left/Right actions, not by focusing
518        // the scrollbar widget itself.
519        let mut handlers = HandlerSet::new().focusable(false);
520
521        // Thumb drag — routed through the typed gesture API. The
522        // framework auto-captures the pointer on `DragPhase::Started`
523        // and releases it on `DragPhase::Ended`, so thumb drags that
524        // leave the widget bounds keep firing.
525        //
526        // A drag that began off the thumb (e.g. on the track) is
527        // deliberately ignored: the `dragging` signal only flips true
528        // when the initial press was on the thumb, and track clicks
529        // are handled by `on_tap` below.
530        {
531            let dragging = dragging.clone();
532            let drag_start_pointer = drag_start_pointer.clone();
533            let drag_start_scroll = drag_start_scroll.clone();
534            let scroll_position = scroll_position.clone();
535            let max_scroll = max_scroll.clone();
536            let set_scroll = set_scroll.clone();
537            let on_thumb = on_thumb.clone();
538            let track_length = track_length.clone();
539            let thumb_length = thumb_length.clone();
540            let mirrored = mirrored.clone();
541            handlers = handlers.on_drag(move |phase, _ctx| {
542                let max = max_scroll.get();
543                if max <= 0.0 {
544                    return;
545                }
546                match phase {
547                    DragPhase::Started {
548                        position,
549                        button: PointerButton::Primary,
550                        ..
551                    } if on_thumb(position) => {
552                        dragging.set(true);
553                        drag_start_pointer.set(axis_value(position));
554                        drag_start_scroll.set(scroll_position.get());
555                    }
556                    DragPhase::Moved { position, .. } if dragging.get() => {
557                        let current = axis_value(position);
558                        // Mirrored: dragging right walks *back* towards the
559                        // start of the content.
560                        let sign = if mirrored() { -1.0 } else { 1.0 };
561                        let delta_pixels = (current - drag_start_pointer.get()) * sign;
562                        let available = track_length() - thumb_length();
563                        if available > 0.0 {
564                            let scroll_delta = delta_pixels * max / available;
565                            set_scroll(drag_start_scroll.get() + scroll_delta);
566                        }
567                    }
568                    DragPhase::Ended { .. } => {
569                        dragging.set(false);
570                    }
571                    _ => {}
572                }
573            });
574        }
575
576        // Track click — page-scroll toward the click position.
577        // The tap recognizer only fires on press+release without
578        // movement past the 5 px threshold, so a thumb grab that
579        // starts as a click but becomes a drag is handled by the
580        // `on_drag` arm above and never reaches here.
581        {
582            let scroll_position = scroll_position.clone();
583            let max_scroll = max_scroll.clone();
584            let viewport_ratio = viewport_ratio.clone();
585            let set_scroll = set_scroll.clone();
586            let thumb_rect = thumb_rect.clone();
587            let mirrored = mirrored.clone();
588            let on_thumb = on_thumb.clone();
589            handlers = handlers.on_tap(move |event, _ctx| {
590                let max = max_scroll.get();
591                if max <= 0.0 {
592                    return;
593                }
594                let tr = thumb_rect();
595                let position = event.position;
596                if on_thumb(position) {
597                    return;
598                }
599                let click_axis = axis_value(position);
600                let thumb_center = match orientation {
601                    ScrollBarOrientation::Vertical => tr.y + tr.height / 2.0,
602                    ScrollBarOrientation::Horizontal => tr.x + tr.width / 2.0,
603                };
604                let ratio = viewport_ratio.get().clamp(0.001, 0.999);
605                let viewport_scroll = max * ratio / (1.0 - ratio);
606                let current = scroll_position.get();
607                // Mirrored: the side of the thumb a click lands on means the
608                // opposite page, because the track runs the other way.
609                let backwards = (click_axis < thumb_center) != mirrored();
610                if backwards {
611                    set_scroll(current - viewport_scroll);
612                } else {
613                    set_scroll(current + viewport_scroll);
614                }
615            });
616        }
617
618        // Hover handler — flips `hovered`. The `active = hovered ||
619        // dragging` derivation that drives Fade visibility lives inside
620        // the recipe style; no need to thread an explicit signal here.
621        {
622            let hovered = hovered.clone();
623            handlers = handlers.on_hover(move |entered, _ctx| {
624                hovered.set(entered);
625            });
626        }
627
628        // Key handler
629        {
630            let scroll_position = scroll_position.clone();
631            let max_scroll = max_scroll.clone();
632            let set_scroll = set_scroll.clone();
633            let ratio = self.viewport_ratio.clone();
634            let mirrored = mirrored.clone();
635            handlers = handlers.on_key(move |event, _ctx| {
636                let max = max_scroll.get();
637                if max <= 0.0 {
638                    return EventResponse::Ignored;
639                }
640                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
641                    return EventResponse::Ignored;
642                };
643                let step = step_size;
644                // One axis only: a vertical bar must leave the horizontal
645                // arrows to the horizontal bar beside it. The direction comes
646                // from the same `mirrored` predicate the thumb, the drag and
647                // the track click use, so all four agree in a right-to-left
648                // window.
649                //
650                // Reachability, stated plainly: this node is `focusable(false)`
651                // and `set_hidden()`, and nothing in the framework focuses it,
652                // so today only a programmatic `tree.focus(..)` — a test, or an
653                // app that opts in — reaches this handler at all.
654                let arrows = match orientation {
655                    ScrollBarOrientation::Vertical => RangeAxis::Vertical,
656                    ScrollBarOrientation::Horizontal => RangeAxis::Horizontal,
657                };
658                let Some(mv) =
659                    range_nav::range_move(*key, *modifiers, RangeKind::Scalar, arrows, mirrored())
660                else {
661                    return EventResponse::Ignored;
662                };
663                // A scroll offset grows *downward*, so `ArrowDown` must add to
664                // it even though `range_nav` reports that as a decrease — the
665                // value's axis and the screen's disagree on the vertical.
666                let horizontal = matches!(orientation, ScrollBarOrientation::Horizontal);
667                match mv {
668                    RangeMove::Step { increase } => {
669                        let d = if range_nav::towards_trailing(increase, horizontal) {
670                            step
671                        } else {
672                            -step
673                        };
674                        set_scroll(scroll_position.get() + d);
675                    }
676                    // A page is one viewport of content. The bar already knows
677                    // the ratio it draws its thumb from, so `max` (which is
678                    // content minus viewport) scaled by `ratio / (1 - ratio)`
679                    // recovers the viewport in the same units — and it degrades
680                    // to the arrow step when the content barely overflows
681                    // rather than jumping nowhere. That the distance is
682                    // measured rather than a multiple of the step is exactly
683                    // what `RangeMove::Page` leaves to the caller.
684                    RangeMove::Page { increase } => {
685                        let p = page_step(&ratio, max, step);
686                        // Deliberately *not* through `towards_trailing`: the
687                        // page keys name a direction in the content, not on
688                        // screen, so unlike the arrows they do not follow the
689                        // bar's orientation. `PageUp` is one viewport back and
690                        // `PageDown` one forward on either axis — which is what
691                        // every scroll view binds them to, and what the
692                        // vertical bar beside a horizontal one already did.
693                        // Reading `increase` geometrically made a horizontal
694                        // bar's `PageUp` scroll *forward*.
695                        set_scroll(scroll_position.get() + if increase { -p } else { p });
696                    }
697                    RangeMove::ToMin => set_scroll(0.0),
698                    RangeMove::ToMax => set_scroll(max),
699                }
700                EventResponse::Handled
701            });
702        }
703
704        // No access-action handler: this node is `set_hidden()`, and assistive
705        // technology scrolls through the parent ScrollView's `Scroll*` actions.
706        // The `SetValue` arm that used to sit here was never advertised, so its
707        // only reachable effect was to answer `Handled` to a `SetValue`
708        // bubbling up from a descendant and drop it.
709
710        ctx.apply_self_handlers(handlers);
711
712        vec![body_id]
713    }
714
715    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
716        match self.orientation {
717            ScrollBarOrientation::Vertical => {
718                Size::new(self.thickness, proposal.height.unwrap_or(100.0))
719            }
720            ScrollBarOrientation::Horizontal => {
721                Size::new(proposal.width.unwrap_or(100.0), self.thickness)
722            }
723        }
724        .into()
725    }
726
727    fn place_children(
728        &self,
729        bounds: Rect,
730        _proposal: SizeProposal,
731        children: &mut [WidgetPlacement],
732        ctx: &LayoutContext,
733    ) {
734        // Cache bounds and direction for event handling: the drag/tap
735        // hit-tests recompute the thumb from these in `build()`, with no
736        // context of their own, and must mirror a horizontal bar the same way
737        // paint does.
738        self.cached_bounds.set(bounds);
739        self.cached_rtl.set(ctx.is_rtl());
740        for child in children.iter_mut() {
741            child.origin = bounds.origin();
742            child.size = bounds.size();
743        }
744    }
745
746    fn children(&self) -> Vec<WidgetId> {
747        self.body_id.into_iter().collect()
748    }
749
750    /// Widen the bar across its scroll axis for a coarse pointer, to the 48 dp
751    /// Android reserves for a scrollbar touch target.
752    ///
753    /// Hit-only: the 8–12 dp paint is untouched at every density, nothing
754    /// relayouts, and a Compact build renders byte for byte as it did. Zero for
755    /// a mouse and for a pen, both of which are precise enough to land on the
756    /// bar as drawn — a pen's tip is where its cursor is, and widening a
757    /// precise pointer's targets takes clicks away from the content.
758    ///
759    /// Only the axis that is too thin grows. Growing the bar *along* the scroll
760    /// axis would claim a strip of content above and below it for no benefit:
761    /// the track already spans the viewport, and its ends are where the corner
762    /// and the other bar live.
763    fn hit_outset(
764        &self,
765        kind: teksilo_tokens::PointerKind,
766        _tokens: &teksilo_tokens::InputTokens,
767    ) -> EdgeInsets {
768        if !matches!(kind, teksilo_tokens::PointerKind::Touch) {
769            return EdgeInsets::ZERO;
770        }
771        let grow = ((SCROLLBAR_COARSE_TARGET - self.thickness) / 2.0).max(0.0);
772        match self.orientation {
773            ScrollBarOrientation::Vertical => EdgeInsets::symmetric(grow, 0.0),
774            ScrollBarOrientation::Horizontal => EdgeInsets::symmetric(0.0, grow),
775        }
776    }
777
778    /// The thumb, and the track either side of it.
779    ///
780    /// Both are paint geometry inside one leaf node: the bar's body is a single
781    /// private painter widget, so without this the thumb does not exist to
782    /// anything outside `paint` — not to the target-conformance audit, and not
783    /// to a router that would route a coarse press to the nearest target. The
784    /// rectangles come from the same three numbers the painters read, so the
785    /// geometry reported and the geometry drawn cannot drift.
786    ///
787    /// A bar with nothing to scroll paints nothing and reports nothing.
788    fn target_regions(&self, bounds: Rect) -> Vec<TargetRegion> {
789        if self.max_scroll.get() <= 0.0 {
790            return Vec::new();
791        }
792        // `thumb_rect` reads the bounds cached by the last `place_children`;
793        // answer in the caller's frame so a query before the first layout, or
794        // after the bar has moved, is still in the space it asked about.
795        let cached = self.cached_bounds.get();
796        let thumb = self.thumb_rect();
797        let thumb = Rect::new(
798            bounds.x + (thumb.x - cached.x),
799            bounds.y + (thumb.y - cached.y),
800            thumb.width,
801            thumb.height,
802        );
803        let mut regions = vec![TargetRegion::grab(thumb, SCROLLBAR_PART_THUMB)];
804        // The track pages on a tap, so it is a target in its own right — but
805        // only the parts of it the thumb has left over.
806        match self.orientation {
807            ScrollBarOrientation::Vertical => {
808                let before = thumb.y - bounds.y;
809                if before > 0.0 {
810                    regions.push(TargetRegion::target(
811                        Rect::new(bounds.x, bounds.y, bounds.width, before),
812                        SCROLLBAR_PART_TRACK,
813                    ));
814                }
815                let after = bounds.bottom() - thumb.bottom();
816                if after > 0.0 {
817                    regions.push(TargetRegion::target(
818                        Rect::new(bounds.x, thumb.bottom(), bounds.width, after),
819                        SCROLLBAR_PART_TRACK,
820                    ));
821                }
822            }
823            ScrollBarOrientation::Horizontal => {
824                let before = thumb.x - bounds.x;
825                if before > 0.0 {
826                    regions.push(TargetRegion::target(
827                        Rect::new(bounds.x, bounds.y, before, bounds.height),
828                        SCROLLBAR_PART_TRACK,
829                    ));
830                }
831                let after = bounds.right() - thumb.right();
832                if after > 0.0 {
833                    regions.push(TargetRegion::target(
834                        Rect::new(thumb.right(), bounds.y, after, bounds.height),
835                        SCROLLBAR_PART_TRACK,
836                    ));
837                }
838            }
839        }
840        regions
841    }
842
843    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
844        // Scrollbars are pointer UI. AT uses ScrollUp/Down/Left/Right on the
845        // parent ScrollView node — exposing the bar itself adds noise without
846        // benefit and creates spurious Tab stops in screen readers.
847        builder.set_hidden();
848    }
849}
850
851/// One viewport of content, in the same units as the scroll position.
852///
853/// `max` is `content - viewport` and `ratio` is `viewport / content`, so
854/// `viewport = max * ratio / (1 - ratio)`. Falls back to the arrow step where
855/// that is degenerate (a full or near-full viewport), so `PageDown` always
856/// moves rather than silently doing nothing.
857fn page_step(ratio: &teksilo_core::signal::Signal<f32>, max: f32, step: f32) -> f32 {
858    let r = ratio.get().clamp(0.0, 1.0);
859    if r <= 0.0 || r >= 1.0 {
860        return step;
861    }
862    let viewport = max * r / (1.0 - r);
863    if viewport.is_finite() && viewport > step {
864        viewport
865    } else {
866        step
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use teksilo_canvas::SizeProposal;
874    use teksilo_core::widget_tree::WidgetTree;
875
876    // A `thumb_color` override must reach the `ScrollBarStyleConfig` the active
877    // style sees, so a custom style (or the recipe) can tint the thumb. Mirrors
878    // how `Button::text_role` flows into `ButtonStyleConfig`.
879    #[test]
880    fn thumb_color_override_threads_into_style_config() {
881        use std::cell::Cell;
882        use std::rc::Rc;
883        use teksilo_core::build_context::BuildContext;
884        use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
885
886        struct RecordingStyle(Rc<Cell<bool>>);
887        impl ScrollBarStyle for RecordingStyle {
888            fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
889                self.0.set(cfg.thumb_color.is_some());
890                ctx.add(crate::primitives::Spacer::new())
891            }
892        }
893
894        let saw_override = Rc::new(Cell::new(false));
895        let mut tree = WidgetTree::new();
896        let bar = ScrollBar::new(
897            ScrollBarOrientation::Vertical,
898            Signal::new(0.0),
899            Signal::new(500.0),
900            Signal::new(0.5),
901        )
902        .style(RecordingStyle(saw_override.clone()))
903        .thumb_color(teksilo_tokens::TextRole::TooltipText);
904        tree.add(bar);
905        tree.layout(SizeProposal::exact(20.0, 200.0));
906        assert!(
907            saw_override.get(),
908            "ScrollBar::thumb_color must thread into ScrollBarStyleConfig::thumb_color"
909        );
910    }
911
912    // ── Keyboard ───────────────────────────────────────────────────
913    //
914    // These reach the handler through a *programmatic* focus, which does not
915    // consult `focusable`. That is deliberate and worth stating: the bar is
916    // `focusable(false)` and `set_hidden()`, and nothing in the framework
917    // focuses it, so today no user keystroke arrives here at all. The handler
918    // is routed through the shared chord table anyway, so it is correct if an
919    // application ever opts a bar into the tab order.
920
921    fn focused_bar(ratio: f32, max: f32) -> (WidgetTree, Signal<f32>, WidgetId) {
922        let position = Signal::new(0.0_f32);
923        let mut tree = WidgetTree::new();
924        let id = tree.add(ScrollBar::new(
925            ScrollBarOrientation::Vertical,
926            position.clone(),
927            Signal::new(max),
928            Signal::new(ratio),
929        ));
930        tree.layout(SizeProposal::exact(20.0, 200.0));
931        tree.focus(id);
932        (tree, position, id)
933    }
934
935    #[test]
936    fn arrows_step_the_position() {
937        use teksilo_core::event::{Key, Modifiers};
938        let (mut tree, position, id) = focused_bar(0.5, 500.0);
939
940        // Stated first, because it is the interesting half: if focus does not
941        // even land on a `focusable(false)` node, no keystroke can reach the
942        // handler and the rest of this module's keyboard is unreachable.
943        assert_eq!(
944            tree.focused(),
945            Some(id),
946            "programmatic focus must land for any of these to mean anything"
947        );
948
949        tree.press_key(Key::ArrowDown, Modifiers::NONE);
950        let after = position.get();
951        assert!(after > 0.0, "ArrowDown scrolls down: {after}");
952
953        tree.press_key(Key::ArrowUp, Modifiers::NONE);
954        assert!(position.get() < after, "ArrowUp scrolls back");
955    }
956
957    #[test]
958    fn a_vertical_bar_ignores_the_horizontal_arrows() {
959        // The horizontal bar beside it owns them; a bar that answered both
960        // would fight its sibling.
961        use teksilo_core::event::{Key, Modifiers};
962        let (mut tree, position, _) = focused_bar(0.5, 500.0);
963
964        for key in [Key::ArrowLeft, Key::ArrowRight] {
965            tree.press_key(key, Modifiers::NONE);
966            assert_eq!(position.get(), 0.0, "{key:?} is not a vertical bar's");
967        }
968    }
969
970    #[test]
971    fn home_and_end_reach_the_ends() {
972        use teksilo_core::event::{Key, Modifiers};
973        let (mut tree, position, _) = focused_bar(0.5, 500.0);
974
975        tree.press_key(Key::End, Modifiers::NONE);
976        assert_eq!(position.get(), 500.0);
977        tree.press_key(Key::Home, Modifiers::NONE);
978        assert_eq!(position.get(), 0.0);
979    }
980
981    #[test]
982    fn page_keys_move_one_measured_viewport() {
983        // The page distance is *measured*, not a multiple of the arrow step:
984        // with `ratio = 0.2` and `max = 400`, the viewport is
985        // `400 * 0.2 / 0.8 = 100`.
986        use teksilo_core::event::{Key, Modifiers};
987        let (mut tree, position, _) = focused_bar(0.2, 400.0);
988
989        tree.press_key(Key::PageDown, Modifiers::NONE);
990        assert!(
991            (position.get() - 100.0).abs() < 0.01,
992            "one viewport is 100, got {}",
993            position.get()
994        );
995        tree.press_key(Key::PageUp, Modifiers::NONE);
996        assert!(position.get().abs() < 0.01);
997    }
998
999    #[test]
1000    fn the_page_keys_read_the_content_not_the_screen() {
1001        // `PageUp` is one viewport *back* and `PageDown` one forward on either
1002        // axis. The horizontal bar used to run them through the same
1003        // `towards_trailing` mapping the arrows use, which reads `increase`
1004        // geometrically — so its `PageUp` scrolled forward and its `PageDown`
1005        // back, the opposite of the vertical bar sitting beside it.
1006        use teksilo_core::event::{Key, Modifiers};
1007
1008        for orientation in [
1009            ScrollBarOrientation::Vertical,
1010            ScrollBarOrientation::Horizontal,
1011        ] {
1012            let position = Signal::new(200.0_f32);
1013            let mut tree = WidgetTree::new();
1014            let id = tree.add(ScrollBar::new(
1015                orientation,
1016                position.clone(),
1017                Signal::new(400.0),
1018                Signal::new(0.2),
1019            ));
1020            tree.layout(match orientation {
1021                ScrollBarOrientation::Vertical => SizeProposal::exact(20.0, 200.0),
1022                ScrollBarOrientation::Horizontal => SizeProposal::exact(200.0, 20.0),
1023            });
1024            tree.focus(id);
1025
1026            tree.press_key(Key::PageDown, Modifiers::NONE);
1027            assert!(
1028                position.get() > 200.0,
1029                "{orientation:?}: PageDown moves forward through the content, got {}",
1030                position.get()
1031            );
1032            let forward = position.get();
1033            tree.press_key(Key::PageUp, Modifiers::NONE);
1034            assert!(
1035                position.get() < forward,
1036                "{orientation:?}: PageUp moves back, got {}",
1037                position.get()
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn an_accelerator_chord_does_not_scroll() {
1044        // Behaviour change: modifiers used to be ignored, so `Ctrl+End` jumped
1045        // to the bottom and swallowed the chord.
1046        use teksilo_core::event::{Key, Modifiers};
1047        let (mut tree, position, _) = focused_bar(0.5, 500.0);
1048
1049        for (key, mods) in [
1050            (Key::End, Modifiers::CTRL),
1051            (Key::PageDown, Modifiers::ALT),
1052            (Key::ArrowDown, Modifiers::SUPER),
1053        ] {
1054            tree.press_key(key, mods);
1055            assert_eq!(
1056                position.get(),
1057                0.0,
1058                "{key:?} with {mods:?} must fall through"
1059            );
1060        }
1061    }
1062
1063    fn make_scrollbar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
1064        let position = Signal::new(0.0_f32);
1065        let max_scroll = Signal::new(500.0_f32);
1066        let viewport_ratio = Signal::new(0.5_f32); // viewport is half of content
1067
1068        let bar = ScrollBar::new(
1069            ScrollBarOrientation::Vertical,
1070            position.clone(),
1071            max_scroll.clone(),
1072            viewport_ratio.clone(),
1073        );
1074        (bar, position, max_scroll, viewport_ratio)
1075    }
1076
1077    #[test]
1078    fn vertical_scrollbar_size() {
1079        let (bar, ..) = make_scrollbar();
1080        let mut tree = WidgetTree::new();
1081        let id = tree.add(bar);
1082        tree.layout(SizeProposal {
1083            width: None,
1084            height: Some(400.0),
1085        });
1086
1087        let bounds = tree.bounds(id);
1088        // Vertical: width = thickness (8), height = proposed (400)
1089        assert!((bounds.width - 8.0).abs() < 0.01);
1090        assert!((bounds.height - 400.0).abs() < 0.01);
1091    }
1092
1093    #[test]
1094    fn horizontal_scrollbar_size() {
1095        let position = Signal::new(0.0_f32);
1096        let max_scroll = Signal::new(500.0_f32);
1097        let viewport_ratio = Signal::new(0.5_f32);
1098
1099        let bar = ScrollBar::new(
1100            ScrollBarOrientation::Horizontal,
1101            position,
1102            max_scroll,
1103            viewport_ratio,
1104        );
1105        let mut tree = WidgetTree::new();
1106        let id = tree.add(bar);
1107        tree.layout(SizeProposal {
1108            width: Some(400.0),
1109            height: None,
1110        });
1111
1112        let bounds = tree.bounds(id);
1113        // Horizontal: width = proposed (400), height = thickness (8)
1114        assert!((bounds.width - 400.0).abs() < 0.01);
1115        assert!((bounds.height - 8.0).abs() < 0.01);
1116    }
1117
1118    #[test]
1119    fn scrollbar_thumb_drag_updates_position() {
1120        let (bar, position, _max, _ratio) = make_scrollbar();
1121        let mut tree = WidgetTree::new();
1122        let _id = tree.add(bar);
1123        tree.layout(SizeProposal::exact(12.0, 400.0));
1124
1125        // Render once to cache bounds
1126        tree.render();
1127
1128        // Initial position is 0
1129        assert!((position.get() - 0.0).abs() < 0.01);
1130
1131        // Pointer down on the thumb (which starts at top)
1132        tree.pointer_move(Point::new(6.0, 10.0));
1133        tree.dispatch_event(WidgetEvent::pointer_down(
1134            Point::new(6.0, 10.0),
1135            PointerButton::Primary,
1136            teksilo_core::event::Modifiers::NONE,
1137        ));
1138
1139        // Drag 100px down: track is 400px, thumb is 200px (50% ratio),
1140        // so available travel = 200px, 100px drag = 50% of travel = 250 scroll.
1141        // DragRecognizer needs one move to cross the 5px threshold and emit
1142        // DragStarted (which carries the *down* position, so the thumb-vs-track
1143        // check latches on), then subsequent moves emit DragMoved with a delta
1144        // from the initial press.
1145        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 20.0)));
1146        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 110.0)));
1147
1148        let pos = position.get();
1149        assert!(pos > 200.0, "Expected scroll > 200, got {}", pos);
1150        assert!(pos < 300.0, "Expected scroll < 300, got {}", pos);
1151    }
1152
1153    #[test]
1154    fn scrollbar_clamps_to_range() {
1155        let (bar, position, max_scroll, ..) = make_scrollbar();
1156        let mut tree = WidgetTree::new();
1157        tree.add(bar);
1158        tree.layout(SizeProposal::exact(12.0, 400.0));
1159        tree.render();
1160
1161        // Repeatedly click far below the thumb to page-scroll forward
1162        // until we hit the maximum (500). Each page scroll adds one
1163        // viewport — `max * ratio / (1 - ratio)`, which is 500 here — so
1164        // the first click already clamps the position at 500.
1165        for _ in 0..5 {
1166            tree.pointer_move(Point::new(6.0, 390.0));
1167            tree.dispatch_event(WidgetEvent::pointer_down(
1168                Point::new(6.0, 390.0),
1169                PointerButton::Primary,
1170                teksilo_core::event::Modifiers::NONE,
1171            ));
1172            // Release so next click isn't a drag
1173            tree.dispatch_event(WidgetEvent::pointer_up(
1174                Point::new(6.0, 390.0),
1175                PointerButton::Primary,
1176                teksilo_core::event::Modifiers::NONE,
1177            ));
1178        }
1179
1180        let pos = position.get();
1181        let max = max_scroll.get();
1182        assert!(
1183            (pos - max).abs() < 0.01,
1184            "Expected pos to be clamped at max={}, got {}",
1185            max,
1186            pos,
1187        );
1188    }
1189
1190    #[test]
1191    fn scrollbar_nothing_to_scroll() {
1192        let position = Signal::new(0.0_f32);
1193        let max_scroll = Signal::new(0.0_f32); // content fits in viewport
1194        let viewport_ratio = Signal::new(1.0_f32);
1195
1196        let bar = ScrollBar::new(
1197            ScrollBarOrientation::Vertical,
1198            position,
1199            max_scroll,
1200            viewport_ratio,
1201        );
1202        let mut tree = WidgetTree::new();
1203        tree.add(bar);
1204        tree.layout(SizeProposal::exact(12.0, 400.0));
1205
1206        let frame = tree.render();
1207        // When max_scroll is 0, the body's `is_idle` gate suppresses
1208        // every paint, so no shapes get queued.
1209        assert!(
1210            frame.shapes.is_empty(),
1211            "Expected no rendering when nothing to scroll"
1212        );
1213    }
1214
1215    #[test]
1216    fn scrollbar_is_hidden_from_at() {
1217        // ScrollBar is a pointer affordance. AT scrolls through the parent
1218        // ScrollView's actions, not by navigating the bar directly.
1219        let (bar, position, _max_scroll, _ratio) = make_scrollbar();
1220        position.set(100.0);
1221
1222        let mut tree = WidgetTree::new();
1223        let id = tree.add(bar);
1224        tree.layout(SizeProposal::exact(12.0, 400.0));
1225
1226        let info = tree.accessibility_node(id);
1227        assert!(info.is_hidden(), "ScrollBar must be hidden from AT");
1228    }
1229
1230    #[test]
1231    fn a_horizontal_bar_mirrors_its_track_in_rtl() {
1232        // `ScrollArea` already anchors its content to the right in a
1233        // right-to-left window and grows `scroll_x` leftward, so the start of
1234        // the content is at the *right*. A thumb pinned to the geometric left
1235        // therefore sat at the far end of the track while the content showed
1236        // its beginning, and a track click paged the wrong way.
1237        use teksilo_core::event::Modifiers;
1238
1239        let position = Signal::new(0.0_f32);
1240        let mut tree = WidgetTree::new();
1241        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1242        let _id = tree.add(ScrollBar::new(
1243            ScrollBarOrientation::Horizontal,
1244            position.clone(),
1245            Signal::new(500.0),
1246            Signal::new(0.5),
1247        ));
1248        tree.layout(SizeProposal::exact(400.0, 12.0));
1249        tree.render();
1250
1251        // At scroll 0 the thumb is flush *right*, so the empty track is on the
1252        // left — clicking there pages forward, the mirror of the LTR case.
1253        let p = Point::new(40.0, 6.0);
1254        tree.pointer_move(p);
1255        tree.dispatch_event(WidgetEvent::pointer_down(
1256            p,
1257            PointerButton::Primary,
1258            Modifiers::NONE,
1259        ));
1260        tree.dispatch_event(WidgetEvent::pointer_up(
1261            p,
1262            PointerButton::Primary,
1263            Modifiers::NONE,
1264        ));
1265
1266        assert!(
1267            position.get() > 0.0,
1268            "a click left of a right-anchored thumb pages forward, got {}",
1269            position.get()
1270        );
1271    }
1272
1273    #[test]
1274    fn a_horizontal_bar_mirrors_its_painted_thumb_in_rtl() {
1275        // The style paints the thumb; the widget hit-tests and drags it. Both
1276        // have to mirror or the two disagree — the painted thumb sat at the
1277        // geometric left while the grab region was on the right, so the thumb
1278        // jumped the moment it was touched. The direction is read at paint
1279        // time, so a locale change needs no rebuild.
1280        let position = Signal::new(0.0_f32);
1281        let mut tree = WidgetTree::new();
1282        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1283        tree.add(ScrollBar::new(
1284            ScrollBarOrientation::Horizontal,
1285            position.clone(),
1286            Signal::new(500.0),
1287            Signal::new(0.5),
1288        ));
1289        tree.layout(SizeProposal::exact(400.0, 12.0));
1290        let frame = tree.render();
1291
1292        // Half a 400 px track is a 200 px thumb; at scroll 0 it is flush with
1293        // the *start* of the content, which is the right edge here.
1294        let thumb = frame
1295            .shapes
1296            .iter()
1297            .find(|q| (q.screen[2] - 200.0).abs() < 1.0)
1298            .expect("the bar paints a 200 px thumb");
1299        assert!(
1300            (thumb.screen[0] - 200.0).abs() < 1.0,
1301            "the thumb must be flush right at scroll 0 under RTL, got x={}",
1302            thumb.screen[0]
1303        );
1304    }
1305
1306    #[test]
1307    fn a_horizontal_bar_mirrors_its_arrows_in_rtl() {
1308        // The arrows key off the same predicate as the thumb and the track
1309        // click, so all four agree rather than each deciding for itself.
1310        use teksilo_core::event::{Key, Modifiers};
1311
1312        let position = Signal::new(200.0_f32);
1313        let mut tree = WidgetTree::new();
1314        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1315        let id = tree.add(ScrollBar::new(
1316            ScrollBarOrientation::Horizontal,
1317            position.clone(),
1318            Signal::new(500.0),
1319            Signal::new(0.5),
1320        ));
1321        tree.layout(SizeProposal::exact(400.0, 12.0));
1322        tree.focus(id);
1323
1324        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1325        assert!(
1326            position.get() > 200.0,
1327            "under RTL the leftward arrow travels forward through the content"
1328        );
1329    }
1330
1331    #[test]
1332    fn a_vertical_bar_ignores_the_layout_direction() {
1333        use teksilo_core::event::{Key, Modifiers};
1334
1335        let position = Signal::new(200.0_f32);
1336        let mut tree = WidgetTree::new();
1337        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1338        let id = tree.add(ScrollBar::new(
1339            ScrollBarOrientation::Vertical,
1340            position.clone(),
1341            Signal::new(500.0),
1342            Signal::new(0.5),
1343        ));
1344        tree.layout(SizeProposal::exact(12.0, 400.0));
1345        tree.focus(id);
1346
1347        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1348        assert!(
1349            position.get() > 200.0,
1350            "there is no leading/trailing on the vertical axis to mirror"
1351        );
1352    }
1353
1354    #[test]
1355    fn track_click_pages_forward() {
1356        let (bar, position, ..) = make_scrollbar();
1357        let mut tree = WidgetTree::new();
1358        let _id = tree.add(bar);
1359        tree.layout(SizeProposal::exact(12.0, 400.0));
1360        tree.render();
1361
1362        // Click on the track below the thumb (thumb starts at top, ~200px tall).
1363        // Track clicks are routed through `on_tap`, which requires a full
1364        // press+release sequence without the pointer crossing the drag
1365        // threshold.
1366        tree.pointer_move(Point::new(6.0, 350.0));
1367        tree.dispatch_event(WidgetEvent::pointer_down(
1368            Point::new(6.0, 350.0),
1369            PointerButton::Primary,
1370            teksilo_core::event::Modifiers::NONE,
1371        ));
1372        tree.dispatch_event(WidgetEvent::pointer_up(
1373            Point::new(6.0, 350.0),
1374            PointerButton::Primary,
1375            teksilo_core::event::Modifiers::NONE,
1376        ));
1377
1378        let pos = position.get();
1379        assert!(
1380            pos > 0.0,
1381            "Expected positive scroll after track click, got {}",
1382            pos
1383        );
1384    }
1385
1386    #[test]
1387    fn scrollbar_drag_inside_scroll_area_updates_position() {
1388        // Regression: reproduces the real-app case where the ScrollBar
1389        // is a child of a ScrollArea (overlay mode), which wraps a tall
1390        // content widget. Before the V2 migration this worked through
1391        // `on_pointer_event`; the drag must keep working through the
1392        // typed `on_drag` + auto-capture path.
1393        use crate::primitives::MinSize;
1394        use crate::scroll_area::{ScrollArea, ScrollBarMode};
1395        use teksilo_canvas::Point;
1396        use teksilo_core::event::{Modifiers, PointerButton};
1397
1398        let mut tree = WidgetTree::new();
1399        // Content is twice as tall as the ScrollArea viewport → v scrollbar
1400        // is needed with viewport_ratio = 0.5.
1401        let content = MinSize::new(400.0, 800.0);
1402        let root = tree.add(
1403            ScrollArea::new()
1404                .child(content)
1405                .scroll_bar_style(ScrollBarMode::Permanent),
1406        );
1407        tree.layout(SizeProposal::exact(400.0, 400.0));
1408        tree.render();
1409
1410        // Find the vertical scrollbar child (second child of ScrollArea:
1411        // content is first, v-scrollbar second).
1412        let sb_id = tree.children(root)[1];
1413        let sb_bounds = tree.bounds(sb_id);
1414        assert!(
1415            sb_bounds.width > 0.0,
1416            "scrollbar should have non-zero width"
1417        );
1418        assert!(
1419            sb_bounds.height > 0.0,
1420            "scrollbar should have non-zero height"
1421        );
1422
1423        // Press in the middle of the thumb (thumb spans y=sb_bounds.y..+half).
1424        let thumb_cx = sb_bounds.x + sb_bounds.width / 2.0;
1425        let thumb_cy = sb_bounds.y + sb_bounds.height / 4.0;
1426        tree.pointer_move(Point::new(thumb_cx, thumb_cy));
1427        tree.dispatch_event(WidgetEvent::pointer_down(
1428            Point::new(thumb_cx, thumb_cy),
1429            PointerButton::Primary,
1430            Modifiers::NONE,
1431        ));
1432
1433        // Cross the drag threshold…
1434        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1435            thumb_cx,
1436            thumb_cy + 10.0,
1437        )));
1438        // …and then actually drag down.
1439        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1440            thumb_cx,
1441            thumb_cy + 100.0,
1442        )));
1443        tree.dispatch_event(WidgetEvent::pointer_up(
1444            Point::new(thumb_cx, thumb_cy + 100.0),
1445            PointerButton::Primary,
1446            Modifiers::NONE,
1447        ));
1448
1449        // Apply the scroll-triggered relayout so the content's cached
1450        // bounds reflect the new scroll offset (the real event loop does
1451        // this automatically every frame).
1452        tree.layout(SizeProposal::exact(400.0, 400.0));
1453
1454        // The scroll position should have advanced by a substantial amount
1455        // (a 100-px drag on a 400-px track with 50 % viewport ratio moves
1456        // the content ~200 px).
1457        let final_scroll = tree.hit_test(Point::new(1.0, 1.0)); // dummy, just keep borrow checker quiet
1458        let _ = final_scroll;
1459        // We can't read scroll_y directly from the public API; assert the
1460        // *bounds* of the content child moved in the ScrollArea's layout
1461        // rect — after layout the content's origin.y is `-scroll_y`.
1462        let content_bounds = tree.bounds(tree.children(root)[0]);
1463        assert!(
1464            content_bounds.y < -1.0,
1465            "content should have scrolled up (y < 0); got y={}",
1466            content_bounds.y
1467        );
1468    }
1469
1470    #[test]
1471    fn drag_release_outside_does_not_stick() {
1472        // Regression test: dragging the thumb and releasing outside the
1473        // scrollbar must not leave `dragging` stuck to true. This requires
1474        // pointer capture so that PointerUp reaches the scrollbar even when
1475        // the pointer is outside its bounds.
1476        let (bar, position, ..) = make_scrollbar();
1477        let mut tree = WidgetTree::new();
1478        let _id = tree.add(bar);
1479        tree.layout(SizeProposal::exact(12.0, 400.0));
1480        tree.render();
1481
1482        // Start drag on the thumb
1483        tree.pointer_move(Point::new(6.0, 10.0));
1484        tree.dispatch_event(WidgetEvent::pointer_down(
1485            Point::new(6.0, 10.0),
1486            PointerButton::Primary,
1487            teksilo_core::event::Modifiers::NONE,
1488        ));
1489
1490        // Move far outside the scrollbar bounds
1491        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(200.0, 300.0)));
1492
1493        // Release outside
1494        tree.dispatch_event(WidgetEvent::pointer_up(
1495            Point::new(200.0, 300.0),
1496            PointerButton::Primary,
1497            teksilo_core::event::Modifiers::NONE,
1498        ));
1499
1500        // Now hover the scrollbar again — should NOT continue dragging
1501        let pos_before = position.get();
1502        tree.pointer_move(Point::new(6.0, 50.0));
1503        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 50.0)));
1504
1505        let pos_after = position.get();
1506        assert!(
1507            (pos_after - pos_before).abs() < 0.01,
1508            "Hovering after release should not move scroll: before={}, after={}",
1509            pos_before,
1510            pos_after,
1511        );
1512    }
1513}
1514
1515/// Reaching the thumb with a finger: the hit mechanisms, the density floor,
1516/// and the axis-only decisions the widened bar forces.
1517#[cfg(test)]
1518mod touch_tests {
1519    use super::*;
1520    use teksilo_canvas::SizeProposal;
1521    use teksilo_core::event::{Modifiers, WidgetEvent};
1522    use teksilo_core::widget::{LayoutContext, Widget};
1523    use teksilo_core::widget_tree::WidgetTree;
1524    use teksilo_tokens::{InputTokens, PenKind, PointerKind, TargetDensity};
1525
1526    /// A 12 dp vertical bar over 400 dp of track, content twice the viewport —
1527    /// so the thumb is half the track and starts at the top.
1528    fn bar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
1529        let position = Signal::new(0.0_f32);
1530        let max_scroll = Signal::new(500.0_f32);
1531        let viewport_ratio = Signal::new(0.5_f32);
1532        let bar = ScrollBar::new(
1533            ScrollBarOrientation::Vertical,
1534            position.clone(),
1535            max_scroll.clone(),
1536            viewport_ratio.clone(),
1537        )
1538        .thickness(12.0);
1539        (bar, position, max_scroll, viewport_ratio)
1540    }
1541
1542    fn mounted() -> (WidgetTree, WidgetId, Signal<f32>) {
1543        let (bar, position, ..) = bar();
1544        let mut tree = WidgetTree::new();
1545        let id = tree.add(bar);
1546        tree.layout(SizeProposal::exact(12.0, 400.0));
1547        tree.render();
1548        (tree, id, position)
1549    }
1550
1551    /// The bar's own reported regions, primed the way a layout pass primes
1552    /// them. The widget is asked directly rather than through
1553    /// [`WidgetTree::widget_target_regions`], so the frame is the caller's to
1554    /// choose, which is also what makes the reported geometry checkable
1555    /// against the arithmetic the painters use rather than against itself.
1556    fn regions_of(bar: &ScrollBar, bounds: Rect) -> Vec<TargetRegion> {
1557        let theme = teksilo_core::presets::intui::light();
1558        let ctx = LayoutContext::for_testing(&theme);
1559        bar.place_children(
1560            bounds,
1561            SizeProposal::exact(bounds.width, bounds.height),
1562            &mut [],
1563            &ctx,
1564        );
1565        bar.target_regions(bounds)
1566    }
1567
1568    // -- target_regions ---------------------------------------------------
1569
1570    /// The reported thumb is the rectangle the style paints: half a 400 dp
1571    /// track at a 0.5 viewport ratio, at the top while the scroll is at zero.
1572    /// Nothing outside this widget can otherwise see it — the whole bar is one
1573    /// leaf node whose body paints the thumb inside its own `paint`.
1574    #[test]
1575    fn target_regions_report_the_thumb_at_its_paint_rect() {
1576        let (bar, position, ..) = bar();
1577        let bounds = Rect::new(0.0, 0.0, 12.0, 400.0);
1578
1579        let regions = regions_of(&bar, bounds);
1580        let thumb = regions
1581            .iter()
1582            .find(|r| r.part == SCROLLBAR_PART_THUMB)
1583            .expect("the thumb is reported");
1584        assert_eq!(thumb.role, TargetRole::Grab);
1585        assert!((thumb.rect.y - bounds.y).abs() < 0.01, "{:?}", thumb.rect);
1586        assert!((thumb.rect.height - 200.0).abs() < 0.01, "{:?}", thumb.rect);
1587        assert!((thumb.rect.width - 12.0).abs() < 0.01);
1588
1589        // Scrolled to the end, the thumb is at the end of the track.
1590        position.set(500.0);
1591        let regions = regions_of(&bar, bounds);
1592        let thumb = regions
1593            .iter()
1594            .find(|r| r.part == SCROLLBAR_PART_THUMB)
1595            .expect("the thumb is reported");
1596        assert!(
1597            (thumb.rect.bottom() - bounds.bottom()).abs() < 0.01,
1598            "{:?}",
1599            thumb.rect
1600        );
1601    }
1602
1603    /// The regions are answered in the frame the caller asked about, so an
1604    /// audit that asks about a bar sitting at an offset gets rectangles there
1605    /// rather than at the origin the last layout happened to use.
1606    #[test]
1607    fn target_regions_answer_in_the_frame_they_were_asked_about() {
1608        let (bar, ..) = bar();
1609        let moved = Rect::new(180.0, 40.0, 12.0, 400.0);
1610        let thumb = regions_of(&bar, moved)
1611            .into_iter()
1612            .find(|r| r.part == SCROLLBAR_PART_THUMB)
1613            .expect("the thumb");
1614        assert!((thumb.rect.x - 180.0).abs() < 0.01, "{:?}", thumb.rect);
1615        assert!((thumb.rect.y - 40.0).abs() < 0.01, "{:?}", thumb.rect);
1616    }
1617
1618    /// The track either side of the thumb is a target too — a tap there pages —
1619    /// and it is exactly the part of the bar the thumb has left over.
1620    #[test]
1621    fn target_regions_report_the_paging_track_around_the_thumb() {
1622        let (bar, position, ..) = bar();
1623        position.set(250.0);
1624        let regions = regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0));
1625        let thumb = regions
1626            .iter()
1627            .find(|r| r.part == SCROLLBAR_PART_THUMB)
1628            .expect("the thumb");
1629        let track: Vec<_> = regions
1630            .iter()
1631            .filter(|r| r.part == SCROLLBAR_PART_TRACK)
1632            .collect();
1633        assert_eq!(track.len(), 2, "one strip above the thumb and one below");
1634        assert!((track[0].rect.bottom() - thumb.rect.y).abs() < 0.01);
1635        assert!((track[1].rect.y - thumb.rect.bottom()).abs() < 0.01);
1636    }
1637
1638    /// A bar with nothing to scroll paints nothing, so it reports nothing.
1639    #[test]
1640    fn a_bar_with_nothing_to_scroll_reports_no_targets() {
1641        let bar = ScrollBar::new(
1642            ScrollBarOrientation::Vertical,
1643            Signal::new(0.0),
1644            Signal::new(0.0),
1645            Signal::new(1.0),
1646        );
1647        assert!(regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0)).is_empty());
1648    }
1649
1650    // -- hit_outset -------------------------------------------------------
1651
1652    /// A finger reaches the bar across 48 dp; the paint stays 12 dp, and the
1653    /// growth is on the cross axis only.
1654    #[test]
1655    fn a_finger_reaches_a_48_dp_bar_over_a_12_dp_paint() {
1656        let (bar, ..) = bar();
1657        let tokens = InputTokens::for_density(TargetDensity::Compact);
1658        let outset = bar.hit_outset(PointerKind::Touch, &tokens);
1659        assert_eq!(outset.leading, 18.0);
1660        assert_eq!(outset.trailing, 18.0);
1661        assert_eq!(outset.top, 0.0, "the track already spans the viewport");
1662        assert_eq!(outset.bottom, 0.0);
1663        assert_eq!(
1664            12.0 + outset.leading + outset.trailing,
1665            SCROLLBAR_COARSE_TARGET
1666        );
1667    }
1668
1669    /// A precise pointer gets nothing — its hot-spot is exact, and widening it
1670    /// would take clicks from the content beside the bar. Same at every
1671    /// density: this is a property of the device, not of the ladder.
1672    #[test]
1673    fn a_precise_pointer_gets_no_outset_at_any_density() {
1674        let (bar, ..) = bar();
1675        for density in [
1676            TargetDensity::Compact,
1677            TargetDensity::Comfortable,
1678            TargetDensity::Touch,
1679        ] {
1680            let tokens = InputTokens::for_density(density);
1681            for kind in [PointerKind::Mouse, PointerKind::Pen(PenKind::Pen)] {
1682                assert_eq!(
1683                    bar.hit_outset(kind, &tokens),
1684                    EdgeInsets::ZERO,
1685                    "{kind:?} at {density:?}"
1686                );
1687            }
1688        }
1689    }
1690
1691    /// A horizontal bar grows the other way.
1692    #[test]
1693    fn a_horizontal_bar_grows_vertically() {
1694        let bar = ScrollBar::new(
1695            ScrollBarOrientation::Horizontal,
1696            Signal::new(0.0),
1697            Signal::new(500.0),
1698            Signal::new(0.5),
1699        )
1700        .thickness(8.0);
1701        let tokens = InputTokens::for_density(TargetDensity::Compact);
1702        let outset = bar.hit_outset(PointerKind::Touch, &tokens);
1703        assert_eq!(outset.top, 20.0);
1704        assert_eq!(outset.bottom, 20.0);
1705        assert_eq!(outset.leading, 0.0);
1706        assert_eq!(outset.trailing, 0.0);
1707    }
1708
1709    // -- the minimum thumb ------------------------------------------------
1710
1711    /// The floor follows the density — 24 dp at Compact, which is exactly the
1712    /// value this widget has always shipped, and 44 dp at Touch.
1713    /// What the widget resolved reaches the style, which is the number the
1714    /// painters actually size the thumb from.
1715    fn floor_seen_by_the_style(density: TargetDensity, explicit: Option<f32>) -> f32 {
1716        use std::cell::Cell;
1717        use std::rc::Rc;
1718        use teksilo_core::build_context::BuildContext;
1719        use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
1720
1721        struct Recording(Rc<Cell<f32>>);
1722        impl ScrollBarStyle for Recording {
1723            fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
1724                self.0.set(cfg.min_thumb_length);
1725                ctx.add(crate::primitives::Spacer::new())
1726            }
1727        }
1728
1729        let seen = Rc::new(Cell::new(f32::NAN));
1730        let mut bar = ScrollBar::new(
1731            ScrollBarOrientation::Vertical,
1732            Signal::new(0.0),
1733            Signal::new(500.0),
1734            Signal::new(0.02),
1735        )
1736        .style(Recording(seen.clone()));
1737        if let Some(explicit) = explicit {
1738            bar = bar.min_thumb_length(explicit);
1739        }
1740        let mut tree = WidgetTree::new();
1741        tree.set_input_density(density);
1742        tree.add(bar);
1743        tree.layout(SizeProposal::exact(12.0, 400.0));
1744        seen.get()
1745    }
1746
1747    /// The floor follows the density — 24 dp at Compact, which is exactly the
1748    /// value this widget has always shipped, and 44 dp at Touch.
1749    #[test]
1750    fn the_minimum_thumb_follows_the_density() {
1751        assert_eq!(floor_seen_by_the_style(TargetDensity::Compact, None), 24.0);
1752        assert_eq!(
1753            floor_seen_by_the_style(TargetDensity::Comfortable, None),
1754            32.0
1755        );
1756        assert_eq!(floor_seen_by_the_style(TargetDensity::Touch, None), 44.0);
1757    }
1758
1759    /// An explicit floor wins over the density.
1760    #[test]
1761    fn an_explicit_minimum_thumb_wins_over_the_density() {
1762        assert_eq!(
1763            floor_seen_by_the_style(TargetDensity::Touch, Some(60.0)),
1764            60.0
1765        );
1766    }
1767
1768    /// …and the floor the widget resolved is the one its own geometry uses, so
1769    /// the thumb an audit reads and the thumb a press lands on are the same
1770    /// rectangle at every density.
1771    #[test]
1772    fn the_resolved_floor_reaches_the_reported_thumb() {
1773        let bar = ScrollBar::new(
1774            ScrollBarOrientation::Vertical,
1775            Signal::new(0.0),
1776            Signal::new(500.0),
1777            Signal::new(0.02),
1778        )
1779        .min_thumb_length(44.0);
1780        let thumb = regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0))
1781            .into_iter()
1782            .find(|r| r.part == SCROLLBAR_PART_THUMB)
1783            .expect("the thumb");
1784        assert_eq!(thumb.rect.height, 44.0);
1785    }
1786
1787    // -- grabbing and paging ----------------------------------------------
1788
1789    /// A press inside the thumb's own paint rectangle grabs it. The bar is
1790    /// widened for a finger, so this is the case the axis-only test has to keep
1791    /// answering the same way it always did.
1792    #[test]
1793    fn the_thumb_is_grabbable_at_its_paint_rect() {
1794        let thumb = regions_of(&bar().0, Rect::new(0.0, 0.0, 12.0, 400.0))
1795            .into_iter()
1796            .find(|r| r.part == SCROLLBAR_PART_THUMB)
1797            .expect("the thumb");
1798        let (mut tree, _id, position) = mounted();
1799        let grab = Point::new(thumb.rect.center().x, thumb.rect.center().y);
1800
1801        tree.pointer_move(grab);
1802        tree.dispatch_event(WidgetEvent::pointer_down(
1803            grab,
1804            PointerButton::Primary,
1805            Modifiers::NONE,
1806        ));
1807        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(grab.x, grab.y + 10.0)));
1808        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1809            grab.x,
1810            grab.y + 100.0,
1811        )));
1812        assert!(
1813            position.get() > 200.0,
1814            "the drag moved the thumb: {}",
1815            position.get()
1816        );
1817    }
1818
1819    /// A finger 13 dp inboard of a 12 dp bar reaches it through the outset —
1820    /// and, being level with the thumb, grabs the thumb rather than paging the
1821    /// track. Deciding thumb-versus-track on the full rectangle would have
1822    /// paged the view out from under the finger that was reaching for the grab,
1823    /// which is why that decision is taken along the scroll axis alone.
1824    #[test]
1825    fn a_finger_reaching_through_the_outset_grabs_the_thumb() {
1826        use teksilo_core::pointer::{
1827            BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
1828            PointerSample,
1829        };
1830
1831        let (bar, position, ..) = bar();
1832        let mut tree = WidgetTree::new();
1833        let bar_id = tree.add(bar);
1834        // The bar at the trailing edge of a 200 dp row, with a spacer taking
1835        // the rest — an overlay bar's arrangement, and the one where the outset
1836        // has content beside it to reach across.
1837        tree.add(
1838            crate::primitives::HStack::new()
1839                .child(crate::primitives::Spacer::new())
1840                .child(bar_id),
1841        );
1842        tree.layout(SizeProposal::exact(200.0, 400.0));
1843        tree.render();
1844        let bounds = tree.bounds(bar_id);
1845        assert!((bounds.width - 12.0).abs() < 0.01, "the paint is unchanged");
1846
1847        let alloc = PointerIdAllocator::global();
1848        let device = BackendDeviceKey::new(0x5B47);
1849        let finger = alloc.begin(device, 1);
1850        alloc.end(device, 1);
1851        let sample = |phase: PointerPhase, at: Point| PointerSample {
1852            pointer: PointerInfo::touch(finger, EventTime::ZERO),
1853            phase,
1854            position: at,
1855            button: None,
1856            modifiers: Modifiers::NONE,
1857            coalesced: Vec::new(),
1858        };
1859
1860        // 13 dp inboard of the bar: off its paint, inside its 18 dp outset,
1861        // and level with a thumb that spans the top half of the track.
1862        let grab = Point::new(bounds.x - 13.0, 100.0);
1863        assert_eq!(
1864            tree.hit_test_for(grab, &PointerInfo::touch(finger, EventTime::ZERO)),
1865            Some(bar_id),
1866            "the outset put the finger on the bar"
1867        );
1868        assert_ne!(
1869            tree.hit_test(grab),
1870            Some(bar_id),
1871            "…and a mouse at the same point lands on the content beside it"
1872        );
1873        tree.dispatch_pointer(sample(PointerPhase::Down, grab));
1874        tree.dispatch_pointer(sample(
1875            PointerPhase::Move,
1876            Point::new(grab.x, grab.y + 30.0),
1877        ));
1878        tree.dispatch_pointer(sample(
1879            PointerPhase::Move,
1880            Point::new(grab.x, grab.y + 100.0),
1881        ));
1882        assert!(
1883            position.get() > 200.0,
1884            "the finger dragged the thumb rather than paging: {}",
1885            position.get()
1886        );
1887    }
1888
1889    /// A tap on the track past the thumb pages one viewport toward it.
1890    #[test]
1891    fn a_track_tap_pages_toward_the_tap() {
1892        let (mut tree, _id, position) = mounted();
1893        let tap = Point::new(6.0, 390.0);
1894        tree.pointer_move(tap);
1895        tree.dispatch_event(WidgetEvent::pointer_down(
1896            tap,
1897            PointerButton::Primary,
1898            Modifiers::NONE,
1899        ));
1900        tree.dispatch_event(WidgetEvent::pointer_up(
1901            tap,
1902            PointerButton::Primary,
1903            Modifiers::NONE,
1904        ));
1905        // max = 500 at a 0.5 ratio, so one viewport is 500 × 0.5 / 0.5 = 500,
1906        // clamped to the end.
1907        assert_eq!(position.get(), 500.0);
1908
1909        // …and a tap above the thumb pages back.
1910        let tap = Point::new(6.0, 10.0);
1911        tree.pointer_move(tap);
1912        tree.dispatch_event(WidgetEvent::pointer_down(
1913            tap,
1914            PointerButton::Primary,
1915            Modifiers::NONE,
1916        ));
1917        tree.dispatch_event(WidgetEvent::pointer_up(
1918            tap,
1919            PointerButton::Primary,
1920            Modifiers::NONE,
1921        ));
1922        assert_eq!(position.get(), 0.0);
1923    }
1924
1925    // -- reveal -----------------------------------------------------------
1926
1927    /// An external reveal reads as hover to the style, which is how an overlay
1928    /// bar becomes visible under a gesture that writes no hover.
1929    #[test]
1930    fn an_external_reveal_reads_as_hover_to_the_style() {
1931        use std::cell::Cell;
1932        use std::rc::Rc;
1933        use teksilo_core::build_context::BuildContext;
1934        use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
1935
1936        #[derive(Clone)]
1937        struct Recording(Rc<Cell<bool>>);
1938        impl ScrollBarStyle for Recording {
1939            fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
1940                self.0.set(cfg.is_hovered.get());
1941                ctx.add(crate::primitives::Spacer::new())
1942            }
1943        }
1944
1945        let hovered = Rc::new(Cell::new(false));
1946        let revealed = Signal::new(false);
1947        let bar = ScrollBar::new(
1948            ScrollBarOrientation::Vertical,
1949            Signal::new(0.0),
1950            Signal::new(500.0),
1951            Signal::new(0.5),
1952        )
1953        .reveal(revealed.clone())
1954        .style(Recording(hovered.clone()));
1955        let mut tree = WidgetTree::new();
1956        tree.add(bar);
1957        tree.layout(SizeProposal::exact(12.0, 400.0));
1958        assert!(!hovered.get(), "nothing has revealed it yet");
1959
1960        revealed.set(true);
1961        tree.layout(SizeProposal::exact(12.0, 400.0));
1962        let bar = ScrollBar::new(
1963            ScrollBarOrientation::Vertical,
1964            Signal::new(0.0),
1965            Signal::new(500.0),
1966            Signal::new(0.5),
1967        )
1968        .reveal(revealed.clone())
1969        .style(Recording(hovered.clone()));
1970        let mut tree = WidgetTree::new();
1971        tree.add(bar);
1972        tree.layout(SizeProposal::exact(12.0, 400.0));
1973        assert!(hovered.get(), "a raised reveal shows the bar");
1974    }
1975
1976    /// A density that reveals every affordance seeds the reveal itself, so a
1977    /// touch build's overlay bar is visible without anyone raising it.
1978    #[test]
1979    fn a_touch_density_reveals_the_bar_at_rest() {
1980        let revealed = Signal::new(false);
1981        let bar = ScrollBar::new(
1982            ScrollBarOrientation::Vertical,
1983            Signal::new(0.0),
1984            Signal::new(500.0),
1985            Signal::new(0.5),
1986        )
1987        .reveal(revealed.clone());
1988        let mut tree = WidgetTree::new();
1989        tree.set_input_density(TargetDensity::Touch);
1990        tree.add(bar);
1991        tree.layout(SizeProposal::exact(12.0, 400.0));
1992        assert!(revealed.get(), "RevealPolicy::Always shows it at rest");
1993    }
1994
1995    /// …and a Compact build does not, which is the invariant every density
1996    /// change in this programme has to keep.
1997    #[test]
1998    fn a_compact_density_leaves_the_bar_at_rest() {
1999        let revealed = Signal::new(false);
2000        let bar = ScrollBar::new(
2001            ScrollBarOrientation::Vertical,
2002            Signal::new(0.0),
2003            Signal::new(500.0),
2004            Signal::new(0.5),
2005        )
2006        .reveal(revealed.clone());
2007        let mut tree = WidgetTree::new();
2008        tree.add(bar);
2009        tree.layout(SizeProposal::exact(12.0, 400.0));
2010        assert!(!revealed.get());
2011    }
2012}