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