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//! ## Accessibility
18//!
19//! Hidden from AT via `set_hidden()`. Scroll actions (Up/Down/Left/Right) are
20//! advertised on the parent `ScrollView` node, not on the bar, so screen readers
21//! navigate the content region directly without stopping on the thumb.
22//!
23//! ```rust
24//! # use teksilo_widgets::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVariant};
25//! # use teksilo_core::signal::Signal;
26//! let position = Signal::new(0.0_f32);
27//! let max_scroll = Signal::new(500.0_f32);
28//! let viewport_ratio = Signal::new(0.4_f32);
29//! let _bar = ScrollBar::new(
30//!     ScrollBarOrientation::Vertical,
31//!     position,
32//!     max_scroll,
33//!     viewport_ratio,
34//! )
35//! .thickness(8.0)
36//! .variant(ScrollBarVariant::Overlay);
37//! ```
38
39use std::cell::Cell;
40use std::rc::Rc;
41
42use teksilo_canvas::{Point, Rect, Size, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::color_prop::ColorProp;
45use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
46use teksilo_core::gesture::DragPhase;
47use teksilo_core::signal::Signal;
48use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig, SharedScrollBarStyle};
49use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
50use teksilo_core::widget_builder::HandlerSet;
51use teksilo_core::widget_id::WidgetId;
52
53// Re-exports so callers can write `ScrollBar::new(..)` /
54// `.visual(ScrollBarVisual::Overlay)` without a deeper import path. The
55// `ScrollBarVisual` alias preserves the historical name; new code can
56// use `ScrollBarVariant` directly.
57pub use teksilo_core::styles::ScrollBarOrientation;
58pub use teksilo_core::styles::ScrollBarVariant;
59pub use teksilo_core::styles::ScrollBarVariant as ScrollBarVisual;
60
61/// A scroll bar that shares reactive scroll-position state with a [`ScrollArea`](crate::scroll_area::ScrollArea).
62///
63/// Supports thumb drag, track-click page scroll, and keyboard
64/// Up/Down/Left/Right/Home/End navigation. Hidden from AT — see module docs.
65pub struct ScrollBar {
66    orientation: ScrollBarOrientation,
67    /// Scroll position: 0.0 = start, max_scroll = end.
68    /// Shared with ScrollArea — both read and write.
69    scroll_position: Signal<f32>,
70    /// Maximum scroll value (content_size - viewport_size).
71    /// Written by the ScrollArea, read by the ScrollBar.
72    max_scroll: Signal<f32>,
73    /// Viewport / content ratio (0.0..1.0). Determines thumb size.
74    /// Written by the ScrollArea, read by the ScrollBar.
75    viewport_ratio: Signal<f32>,
76
77    // --- interaction state ---
78    /// Whether the pointer is over the scroll bar.
79    hovered: Signal<bool>,
80    /// Whether the thumb is being dragged.
81    dragging: Signal<bool>,
82    /// Pointer position at drag start (in scroll bar local coords).
83    drag_start_pointer: Rc<Cell<f32>>,
84    /// Scroll position at drag start.
85    drag_start_scroll: Rc<Cell<f32>>,
86    /// Current bounds, cached from last layout for event handling.
87    cached_bounds: Rc<Cell<Rect>>,
88    /// Body subtree id returned by the active style — kept in
89    /// `children()` so layout traverses through it.
90    body_id: Option<WidgetId>,
91
92    // --- visual tuning ---
93    /// Thickness of the scroll bar (width for vertical, height for horizontal).
94    thickness: f32,
95    /// Minimum thumb length in pixels.
96    min_thumb_length: f32,
97    /// Pixels to scroll per keyboard step.
98    step_size: f32,
99    /// Visual variant: Permanent / Overlay / Thin.
100    variant: ScrollBarVariant,
101    /// Per-call style override.
102    style_override: Option<SharedScrollBarStyle>,
103    /// Optional thumb tint. `None` → the style paints from the theme's
104    /// `scrollbar_thumb*` tokens; `Some` → tint from this `ColorProp`
105    /// (resolved at paint, so a role / `Signal` stays reactive). See
106    /// [`Self::thumb_color`].
107    thumb_color: Option<ColorProp>,
108}
109
110impl std::fmt::Debug for ScrollBar {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("ScrollBar")
113            .field("orientation", &self.orientation)
114            .field("hovered", &self.hovered.get())
115            .field("dragging", &self.dragging.get())
116            .field("variant", &self.variant)
117            .finish()
118    }
119}
120
121impl ScrollBar {
122    /// Create a new ScrollBar with shared state.
123    ///
124    /// - `scroll_position`: shared `Signal<f32>` for current scroll offset
125    /// - `max_scroll`: shared `Signal<f32>` for maximum scroll offset
126    /// - `viewport_ratio`: shared `Signal<f32>` for viewport/content ratio (0.0..1.0)
127    pub fn new(
128        orientation: ScrollBarOrientation,
129        scroll_position: Signal<f32>,
130        max_scroll: Signal<f32>,
131        viewport_ratio: Signal<f32>,
132    ) -> Self {
133        // Defaults sourced from `ScrollBarStyle` (Int UI: 8 dp on hover,
134        // 4 dp at idle, 24 dp minimum thumb length).
135        Self {
136            orientation,
137            scroll_position,
138            max_scroll,
139            viewport_ratio,
140            hovered: Signal::new(false),
141            dragging: Signal::new(false),
142            drag_start_pointer: Rc::new(Cell::new(0.0)),
143            drag_start_scroll: Rc::new(Cell::new(0.0)),
144            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
145            body_id: None,
146            thickness: 8.0,
147            min_thumb_length: 24.0,
148            step_size: 40.0,
149            variant: ScrollBarVariant::default(),
150            style_override: None,
151            thumb_color: None,
152        }
153    }
154
155    /// Set the bar thickness (width for vertical, height for horizontal).
156    pub fn thickness(mut self, thickness: f32) -> Self {
157        self.thickness = thickness;
158        self
159    }
160
161    /// Set the minimum thumb length in pixels.
162    pub fn min_thumb_length(mut self, len: f32) -> Self {
163        self.min_thumb_length = len;
164        self
165    }
166
167    /// Set the scroll step for keyboard navigation.
168    pub fn step_size(mut self, step: f32) -> Self {
169        self.step_size = step;
170        self
171    }
172
173    /// Set the visual variant. The active [`ScrollBarStyle`] picks how
174    /// to paint each variant; the IntUI default ships Permanent /
175    /// Overlay / Thin out of the box.
176    pub fn visual(mut self, variant: ScrollBarVariant) -> Self {
177        self.variant = variant;
178        self
179    }
180
181    /// Alias for `visual` using the new variant naming.
182    pub fn variant(mut self, variant: ScrollBarVariant) -> Self {
183        self.variant = variant;
184        self
185    }
186
187    /// Override the active [`ScrollBarStyle`] for this widget instance only.
188    pub fn style(mut self, style: impl ScrollBarStyle) -> Self {
189        self.style_override = Some(Rc::new(style));
190        self
191    }
192
193    /// Tint the thumb with an explicit colour instead of the theme's
194    /// `scrollbar_thumb*` tokens. Accepts anything `impl Into<ColorProp>` —
195    /// a `Color`, a theme role (`TextRole`/`SurfaceRole`/…), or a `Signal`;
196    /// resolved against the live theme at paint, so roles and signals stay
197    /// reactive. The active [`ScrollBarStyle`] derives the idle/hover/pressed
198    /// states from this tint. Use when the bar sits on a surface the
199    /// surface-relative tokens don't suit — a tooltip's inverse chip, a
200    /// branded panel. Mirrors [`Button::text_role`](crate::button::Button::text_role).
201    pub fn thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
202        self.thumb_color = Some(color.into());
203        self
204    }
205
206    // --- geometry helpers (kept on the parent because event handlers
207    // need them; the style body re-derives the same numbers from cfg).
208
209    /// The total length of the track (along the scroll axis).
210    fn track_length(&self) -> f32 {
211        let bounds = self.cached_bounds.get();
212        match self.orientation {
213            ScrollBarOrientation::Vertical => bounds.height,
214            ScrollBarOrientation::Horizontal => bounds.width,
215        }
216    }
217
218    /// Computed thumb length based on viewport ratio.
219    fn thumb_length(&self) -> f32 {
220        let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
221        let track = self.track_length();
222        (track * ratio).max(self.min_thumb_length).min(track)
223    }
224
225    /// Thumb offset from the start of the track.
226    fn thumb_offset(&self) -> f32 {
227        let max = self.max_scroll.get();
228        if max <= 0.0 {
229            return 0.0;
230        }
231        let pos = self.scroll_position.get();
232        let ratio = (pos / max).clamp(0.0, 1.0);
233        let available = self.track_length() - self.thumb_length();
234        ratio * available
235    }
236
237    /// The thumb rect in absolute coordinates.
238    fn thumb_rect(&self) -> Rect {
239        let bounds = self.cached_bounds.get();
240        let offset = self.thumb_offset();
241        let thumb_len = self.thumb_length();
242        match self.orientation {
243            ScrollBarOrientation::Vertical => {
244                Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
245            }
246            ScrollBarOrientation::Horizontal => {
247                Rect::new(bounds.x + offset, bounds.y, thumb_len, bounds.height)
248            }
249        }
250    }
251}
252
253impl Widget for ScrollBar {
254    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
255        // Resolve the active style: per-call override > theme slot >
256        // built-in `RecipeScrollBarStyle` default.
257        let style: SharedScrollBarStyle = self
258            .style_override
259            .clone()
260            .or_else(|| ctx.theme().style_slots.scroll_bar.clone())
261            .unwrap_or_else(|| Rc::new(crate::styles::RecipeScrollBarStyle::default()));
262
263        // Derived `scroll_ratio = scroll_position / max_scroll` (clamped
264        // to 0..1). Re-renders the body on every scroll.
265        let scroll_ratio = self
266            .scroll_position
267            .zip(&self.max_scroll)
268            .map(|(pos, max)| {
269                if *max <= 0.0 {
270                    0.0
271                } else {
272                    (*pos / *max).clamp(0.0, 1.0)
273                }
274            });
275        // `is_idle = max_scroll == 0` — body paints nothing in this case.
276        let is_idle = self.max_scroll.map(|m| *m <= 0.0);
277
278        let cfg = ScrollBarStyleConfig {
279            scroll_ratio,
280            viewport_ratio: self.viewport_ratio.clone(),
281            is_hovered: self.hovered.clone(),
282            is_dragging: self.dragging.clone(),
283            is_idle,
284            orientation: self.orientation,
285            variant: self.variant,
286            min_thumb_length: self.min_thumb_length,
287            thumb_color: self.thumb_color.clone(),
288        };
289        let body_id = style.make_body(&cfg, ctx);
290        self.body_id = Some(body_id);
291
292        let orientation = self.orientation;
293        let scroll_position = self.scroll_position.clone();
294        let max_scroll = self.max_scroll.clone();
295        let viewport_ratio = self.viewport_ratio.clone();
296        let hovered = self.hovered.clone();
297        let dragging = self.dragging.clone();
298        let drag_start_pointer = self.drag_start_pointer.clone();
299        let drag_start_scroll = self.drag_start_scroll.clone();
300        let cached_bounds = self.cached_bounds.clone();
301        let step_size = self.step_size;
302        let min_thumb_length = self.min_thumb_length;
303
304        let axis_value = move |point: Point| -> f32 {
305            match orientation {
306                ScrollBarOrientation::Vertical => point.y,
307                ScrollBarOrientation::Horizontal => point.x,
308            }
309        };
310
311        let set_scroll = {
312            let scroll_position = scroll_position.clone();
313            let max_scroll = max_scroll.clone();
314            move |value: f32| {
315                let max = max_scroll.get();
316                scroll_position.set(value.clamp(0.0, max));
317            }
318        };
319
320        let track_length = {
321            let cached_bounds = cached_bounds.clone();
322            move || -> f32 {
323                let bounds = cached_bounds.get();
324                match orientation {
325                    ScrollBarOrientation::Vertical => bounds.height,
326                    ScrollBarOrientation::Horizontal => bounds.width,
327                }
328            }
329        };
330
331        let thumb_length = {
332            let viewport_ratio = viewport_ratio.clone();
333            let track_length = track_length.clone();
334            move || -> f32 {
335                let ratio = viewport_ratio.get().clamp(0.0, 1.0);
336                let track = track_length();
337                (track * ratio).max(min_thumb_length).min(track)
338            }
339        };
340
341        let thumb_rect = {
342            let cached_bounds = cached_bounds.clone();
343            let scroll_position = scroll_position.clone();
344            let max_scroll = max_scroll.clone();
345            let track_length = track_length.clone();
346            let thumb_length = thumb_length.clone();
347            move || -> Rect {
348                let bounds = cached_bounds.get();
349                let max = max_scroll.get();
350                let offset = if max <= 0.0 {
351                    0.0
352                } else {
353                    let pos = scroll_position.get();
354                    let ratio = (pos / max).clamp(0.0, 1.0);
355                    let available = track_length() - thumb_length();
356                    ratio * available
357                };
358                let tl = thumb_length();
359                // Widget-local thumb rect (origin at the scrollbar's own
360                // top-left): event positions arrive widget-local, so the
361                // cross-axis origin is 0, not `bounds.x` / `bounds.y`.
362                match orientation {
363                    ScrollBarOrientation::Vertical => Rect::new(0.0, offset, bounds.width, tl),
364                    ScrollBarOrientation::Horizontal => Rect::new(offset, 0.0, tl, bounds.height),
365                }
366            }
367        };
368
369        // Scrollbars are pointer affordances. AT scrolls through the parent
370        // ScrollView node's ScrollUp/Down/Left/Right actions, not by focusing
371        // the scrollbar widget itself.
372        let mut handlers = HandlerSet::new().focusable(false);
373
374        // Thumb drag — routed through the typed gesture API. The
375        // framework auto-captures the pointer on `DragPhase::Started`
376        // and releases it on `DragPhase::Ended`, so thumb drags that
377        // leave the widget bounds keep firing.
378        //
379        // A drag that began off the thumb (e.g. on the track) is
380        // deliberately ignored: the `dragging` signal only flips true
381        // when the initial press was on the thumb, and track clicks
382        // are handled by `on_tap` below.
383        {
384            let dragging = dragging.clone();
385            let drag_start_pointer = drag_start_pointer.clone();
386            let drag_start_scroll = drag_start_scroll.clone();
387            let scroll_position = scroll_position.clone();
388            let max_scroll = max_scroll.clone();
389            let set_scroll = set_scroll.clone();
390            let thumb_rect = thumb_rect.clone();
391            let track_length = track_length.clone();
392            let thumb_length = thumb_length.clone();
393            handlers = handlers.on_drag(move |phase, _ctx| {
394                let max = max_scroll.get();
395                if max <= 0.0 {
396                    return;
397                }
398                match phase {
399                    DragPhase::Started {
400                        position,
401                        button: PointerButton::Primary,
402                    } if thumb_rect().contains(position) => {
403                        dragging.set(true);
404                        drag_start_pointer.set(axis_value(position));
405                        drag_start_scroll.set(scroll_position.get());
406                    }
407                    DragPhase::Moved { position, .. } if dragging.get() => {
408                        let current = axis_value(position);
409                        let delta_pixels = current - drag_start_pointer.get();
410                        let available = track_length() - thumb_length();
411                        if available > 0.0 {
412                            let scroll_delta = delta_pixels * max / available;
413                            set_scroll(drag_start_scroll.get() + scroll_delta);
414                        }
415                    }
416                    DragPhase::Ended { .. } => {
417                        dragging.set(false);
418                    }
419                    _ => {}
420                }
421            });
422        }
423
424        // Track click — page-scroll toward the click position.
425        // The tap recognizer only fires on press+release without
426        // movement past the 5 px threshold, so a thumb grab that
427        // starts as a click but becomes a drag is handled by the
428        // `on_drag` arm above and never reaches here.
429        {
430            let scroll_position = scroll_position.clone();
431            let max_scroll = max_scroll.clone();
432            let viewport_ratio = viewport_ratio.clone();
433            let set_scroll = set_scroll.clone();
434            let thumb_rect = thumb_rect.clone();
435            handlers = handlers.on_tap(move |event, _ctx| {
436                let max = max_scroll.get();
437                if max <= 0.0 {
438                    return;
439                }
440                let tr = thumb_rect();
441                let position = event.position;
442                if tr.contains(position) {
443                    return;
444                }
445                let click_axis = axis_value(position);
446                let thumb_center = match orientation {
447                    ScrollBarOrientation::Vertical => tr.y + tr.height / 2.0,
448                    ScrollBarOrientation::Horizontal => tr.x + tr.width / 2.0,
449                };
450                let ratio = viewport_ratio.get().clamp(0.001, 0.999);
451                let viewport_scroll = max * ratio / (1.0 - ratio);
452                let current = scroll_position.get();
453                if click_axis < thumb_center {
454                    set_scroll(current - viewport_scroll);
455                } else {
456                    set_scroll(current + viewport_scroll);
457                }
458            });
459        }
460
461        // Hover handler — flips `hovered`. The `active = hovered ||
462        // dragging` derivation that drives Fade visibility lives inside
463        // the recipe style; no need to thread an explicit signal here.
464        {
465            let hovered = hovered.clone();
466            handlers = handlers.on_hover(move |entered, _ctx| {
467                hovered.set(entered);
468            });
469        }
470
471        // Key handler
472        {
473            let scroll_position = scroll_position.clone();
474            let max_scroll = max_scroll.clone();
475            let set_scroll = set_scroll.clone();
476            let ratio = self.viewport_ratio.clone();
477            handlers = handlers.on_key(move |event, _ctx| {
478                let max = max_scroll.get();
479                if max <= 0.0 {
480                    return EventResponse::Ignored;
481                }
482                match event {
483                    WidgetEvent::KeyDown { key, .. } => {
484                        use teksilo_core::event::Key;
485                        let step = step_size;
486                        match (orientation, key) {
487                            (ScrollBarOrientation::Vertical, Key::ArrowUp) => {
488                                set_scroll(scroll_position.get() - step);
489                                EventResponse::Handled
490                            }
491                            (ScrollBarOrientation::Vertical, Key::ArrowDown) => {
492                                set_scroll(scroll_position.get() + step);
493                                EventResponse::Handled
494                            }
495                            (ScrollBarOrientation::Horizontal, Key::ArrowLeft) => {
496                                set_scroll(scroll_position.get() - step);
497                                EventResponse::Handled
498                            }
499                            (ScrollBarOrientation::Horizontal, Key::ArrowRight) => {
500                                set_scroll(scroll_position.get() + step);
501                                EventResponse::Handled
502                            }
503                            (_, Key::Home) => {
504                                set_scroll(0.0);
505                                EventResponse::Handled
506                            }
507                            (_, Key::End) => {
508                                set_scroll(max);
509                                EventResponse::Handled
510                            }
511                            // A page is one viewport of content. The bar
512                            // already knows the ratio it draws its thumb from,
513                            // so `max` (which is content minus viewport)
514                            // scaled by `ratio / (1 - ratio)` recovers the
515                            // viewport in the same units — and it degrades to
516                            // the arrow step when the content barely overflows
517                            // rather than jumping nowhere.
518                            (_, Key::PageUp) => {
519                                set_scroll(scroll_position.get() - page_step(&ratio, max, step));
520                                EventResponse::Handled
521                            }
522                            (_, Key::PageDown) => {
523                                set_scroll(scroll_position.get() + page_step(&ratio, max, step));
524                                EventResponse::Handled
525                            }
526                            _ => EventResponse::Ignored,
527                        }
528                    }
529                    _ => EventResponse::Ignored,
530                }
531            });
532        }
533
534        // Access action handler
535        {
536            handlers = handlers.on_access_action(move |action, _ctx| {
537                if action == teksilo_core::accesskit::Action::SetValue {
538                    EventResponse::Handled
539                } else {
540                    EventResponse::Ignored
541                }
542            });
543        }
544
545        ctx.apply_self_handlers(handlers);
546
547        vec![body_id]
548    }
549
550    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
551        match self.orientation {
552            ScrollBarOrientation::Vertical => {
553                Size::new(self.thickness, proposal.height.unwrap_or(100.0))
554            }
555            ScrollBarOrientation::Horizontal => {
556                Size::new(proposal.width.unwrap_or(100.0), self.thickness)
557            }
558        }
559        .into()
560    }
561
562    fn place_children(
563        &self,
564        bounds: Rect,
565        _proposal: SizeProposal,
566        children: &mut [WidgetPlacement],
567        _ctx: &LayoutContext,
568    ) {
569        // Cache bounds for event handling (drag/tap hit-tests against
570        // `self.thumb_rect()`, which reads `cached_bounds`).
571        self.cached_bounds.set(bounds);
572        for child in children.iter_mut() {
573            child.origin = bounds.origin();
574            child.size = bounds.size();
575        }
576    }
577
578    fn children(&self) -> Vec<WidgetId> {
579        self.body_id.into_iter().collect()
580    }
581
582    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
583        // Scrollbars are pointer UI. AT uses ScrollUp/Down/Left/Right on the
584        // parent ScrollView node — exposing the bar itself adds noise without
585        // benefit and creates spurious Tab stops in screen readers.
586        builder.set_hidden();
587    }
588}
589
590/// One viewport of content, in the same units as the scroll position.
591///
592/// `max` is `content - viewport` and `ratio` is `viewport / content`, so
593/// `viewport = max * ratio / (1 - ratio)`. Falls back to the arrow step where
594/// that is degenerate (a full or near-full viewport), so `PageDown` always
595/// moves rather than silently doing nothing.
596fn page_step(ratio: &teksilo_core::signal::Signal<f32>, max: f32, step: f32) -> f32 {
597    let r = ratio.get().clamp(0.0, 1.0);
598    if r <= 0.0 || r >= 1.0 {
599        return step;
600    }
601    let viewport = max * r / (1.0 - r);
602    if viewport.is_finite() && viewport > step {
603        viewport
604    } else {
605        step
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use teksilo_canvas::SizeProposal;
613    use teksilo_core::widget_tree::WidgetTree;
614
615    // A `thumb_color` override must reach the `ScrollBarStyleConfig` the active
616    // style sees, so a custom style (or the recipe) can tint the thumb. Mirrors
617    // how `Button::text_role` flows into `ButtonStyleConfig`.
618    #[test]
619    fn thumb_color_override_threads_into_style_config() {
620        use std::cell::Cell;
621        use std::rc::Rc;
622        use teksilo_core::build_context::BuildContext;
623        use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
624
625        struct RecordingStyle(Rc<Cell<bool>>);
626        impl ScrollBarStyle for RecordingStyle {
627            fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
628                self.0.set(cfg.thumb_color.is_some());
629                ctx.add(crate::primitives::Spacer::new())
630            }
631        }
632
633        let saw_override = Rc::new(Cell::new(false));
634        let mut tree = WidgetTree::new();
635        let bar = ScrollBar::new(
636            ScrollBarOrientation::Vertical,
637            Signal::new(0.0),
638            Signal::new(500.0),
639            Signal::new(0.5),
640        )
641        .style(RecordingStyle(saw_override.clone()))
642        .thumb_color(teksilo_tokens::TextRole::TooltipText);
643        tree.add(bar);
644        tree.layout(SizeProposal::exact(20.0, 200.0));
645        assert!(
646            saw_override.get(),
647            "ScrollBar::thumb_color must thread into ScrollBarStyleConfig::thumb_color"
648        );
649    }
650
651    fn make_scrollbar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
652        let position = Signal::new(0.0_f32);
653        let max_scroll = Signal::new(500.0_f32);
654        let viewport_ratio = Signal::new(0.5_f32); // viewport is half of content
655
656        let bar = ScrollBar::new(
657            ScrollBarOrientation::Vertical,
658            position.clone(),
659            max_scroll.clone(),
660            viewport_ratio.clone(),
661        );
662        (bar, position, max_scroll, viewport_ratio)
663    }
664
665    #[test]
666    fn vertical_scrollbar_size() {
667        let (bar, ..) = make_scrollbar();
668        let mut tree = WidgetTree::new();
669        let id = tree.add(bar);
670        tree.layout(SizeProposal {
671            width: None,
672            height: Some(400.0),
673        });
674
675        let bounds = tree.bounds(id);
676        // Vertical: width = thickness (8), height = proposed (400)
677        assert!((bounds.width - 8.0).abs() < 0.01);
678        assert!((bounds.height - 400.0).abs() < 0.01);
679    }
680
681    #[test]
682    fn horizontal_scrollbar_size() {
683        let position = Signal::new(0.0_f32);
684        let max_scroll = Signal::new(500.0_f32);
685        let viewport_ratio = Signal::new(0.5_f32);
686
687        let bar = ScrollBar::new(
688            ScrollBarOrientation::Horizontal,
689            position,
690            max_scroll,
691            viewport_ratio,
692        );
693        let mut tree = WidgetTree::new();
694        let id = tree.add(bar);
695        tree.layout(SizeProposal {
696            width: Some(400.0),
697            height: None,
698        });
699
700        let bounds = tree.bounds(id);
701        // Horizontal: width = proposed (400), height = thickness (8)
702        assert!((bounds.width - 400.0).abs() < 0.01);
703        assert!((bounds.height - 8.0).abs() < 0.01);
704    }
705
706    #[test]
707    fn scrollbar_thumb_drag_updates_position() {
708        let (bar, position, _max, _ratio) = make_scrollbar();
709        let mut tree = WidgetTree::new();
710        let _id = tree.add(bar);
711        tree.layout(SizeProposal::exact(12.0, 400.0));
712
713        // Render once to cache bounds
714        tree.render();
715
716        // Initial position is 0
717        assert!((position.get() - 0.0).abs() < 0.01);
718
719        // Pointer down on the thumb (which starts at top)
720        tree.pointer_move(Point::new(6.0, 10.0));
721        tree.dispatch_event(WidgetEvent::PointerDown {
722            position: Point::new(6.0, 10.0),
723            button: PointerButton::Primary,
724            modifiers: teksilo_core::event::Modifiers::NONE,
725        });
726
727        // Drag 100px down: track is 400px, thumb is 200px (50% ratio),
728        // so available travel = 200px, 100px drag = 50% of travel = 250 scroll.
729        // DragRecognizer needs one move to cross the 5px threshold and emit
730        // DragStarted (which carries the *down* position, so the thumb-vs-track
731        // check latches on), then subsequent moves emit DragMoved with a delta
732        // from the initial press.
733        tree.dispatch_event(WidgetEvent::PointerMove {
734            position: Point::new(6.0, 20.0),
735        });
736        tree.dispatch_event(WidgetEvent::PointerMove {
737            position: Point::new(6.0, 110.0),
738        });
739
740        let pos = position.get();
741        assert!(pos > 200.0, "Expected scroll > 200, got {}", pos);
742        assert!(pos < 300.0, "Expected scroll < 300, got {}", pos);
743    }
744
745    #[test]
746    fn scrollbar_clamps_to_range() {
747        let (bar, position, max_scroll, ..) = make_scrollbar();
748        let mut tree = WidgetTree::new();
749        tree.add(bar);
750        tree.layout(SizeProposal::exact(12.0, 400.0));
751        tree.render();
752
753        // Repeatedly click far below the thumb to page-scroll forward
754        // until we hit the maximum (500). Each page scroll adds 250,
755        // so after 3 clicks the position should be clamped at 500.
756        for _ in 0..5 {
757            tree.pointer_move(Point::new(6.0, 390.0));
758            tree.dispatch_event(WidgetEvent::PointerDown {
759                position: Point::new(6.0, 390.0),
760                button: PointerButton::Primary,
761                modifiers: teksilo_core::event::Modifiers::NONE,
762            });
763            // Release so next click isn't a drag
764            tree.dispatch_event(WidgetEvent::PointerUp {
765                position: Point::new(6.0, 390.0),
766                button: PointerButton::Primary,
767                modifiers: teksilo_core::event::Modifiers::NONE,
768            });
769        }
770
771        let pos = position.get();
772        let max = max_scroll.get();
773        assert!(
774            (pos - max).abs() < 0.01,
775            "Expected pos to be clamped at max={}, got {}",
776            max,
777            pos,
778        );
779    }
780
781    #[test]
782    fn scrollbar_nothing_to_scroll() {
783        let position = Signal::new(0.0_f32);
784        let max_scroll = Signal::new(0.0_f32); // content fits in viewport
785        let viewport_ratio = Signal::new(1.0_f32);
786
787        let bar = ScrollBar::new(
788            ScrollBarOrientation::Vertical,
789            position,
790            max_scroll,
791            viewport_ratio,
792        );
793        let mut tree = WidgetTree::new();
794        tree.add(bar);
795        tree.layout(SizeProposal::exact(12.0, 400.0));
796
797        let frame = tree.render();
798        // When max_scroll is 0, the body's `is_idle` gate suppresses
799        // every paint, so no shapes get queued.
800        assert!(
801            frame.shapes.is_empty(),
802            "Expected no rendering when nothing to scroll"
803        );
804    }
805
806    #[test]
807    fn scrollbar_is_hidden_from_at() {
808        // ScrollBar is a pointer affordance. AT scrolls through the parent
809        // ScrollView's actions, not by navigating the bar directly.
810        let (bar, position, _max_scroll, _ratio) = make_scrollbar();
811        position.set(100.0);
812
813        let mut tree = WidgetTree::new();
814        let id = tree.add(bar);
815        tree.layout(SizeProposal::exact(12.0, 400.0));
816
817        let info = tree.accessibility_node(id);
818        assert!(info.is_hidden(), "ScrollBar must be hidden from AT");
819    }
820
821    #[test]
822    fn track_click_pages_forward() {
823        let (bar, position, ..) = make_scrollbar();
824        let mut tree = WidgetTree::new();
825        let _id = tree.add(bar);
826        tree.layout(SizeProposal::exact(12.0, 400.0));
827        tree.render();
828
829        // Click on the track below the thumb (thumb starts at top, ~200px tall).
830        // Track clicks are routed through `on_tap`, which requires a full
831        // press+release sequence without the pointer crossing the drag
832        // threshold.
833        tree.pointer_move(Point::new(6.0, 350.0));
834        tree.dispatch_event(WidgetEvent::PointerDown {
835            position: Point::new(6.0, 350.0),
836            button: PointerButton::Primary,
837            modifiers: teksilo_core::event::Modifiers::NONE,
838        });
839        tree.dispatch_event(WidgetEvent::PointerUp {
840            position: Point::new(6.0, 350.0),
841            button: PointerButton::Primary,
842            modifiers: teksilo_core::event::Modifiers::NONE,
843        });
844
845        let pos = position.get();
846        assert!(
847            pos > 0.0,
848            "Expected positive scroll after track click, got {}",
849            pos
850        );
851    }
852
853    #[test]
854    fn scrollbar_drag_inside_scroll_area_updates_position() {
855        // Regression: reproduces the real-app case where the ScrollBar
856        // is a child of a ScrollArea (overlay mode), which wraps a tall
857        // content widget. Before the V2 migration this worked through
858        // `on_pointer_event`; the drag must keep working through the
859        // typed `on_drag` + auto-capture path.
860        use crate::primitives::MinSize;
861        use crate::scroll_area::{ScrollArea, ScrollBarMode};
862        use teksilo_canvas::Point;
863        use teksilo_core::event::{Modifiers, PointerButton};
864
865        let mut tree = WidgetTree::new();
866        // Content is twice as tall as the ScrollArea viewport → v scrollbar
867        // is needed with viewport_ratio = 0.5.
868        let content = MinSize::new(400.0, 800.0);
869        let root = tree.add(
870            ScrollArea::new()
871                .child(content)
872                .scroll_bar_style(ScrollBarMode::Permanent),
873        );
874        tree.layout(SizeProposal::exact(400.0, 400.0));
875        tree.render();
876
877        // Find the vertical scrollbar child (second child of ScrollArea:
878        // content is first, v-scrollbar second).
879        let sb_id = tree.children(root)[1];
880        let sb_bounds = tree.bounds(sb_id);
881        assert!(
882            sb_bounds.width > 0.0,
883            "scrollbar should have non-zero width"
884        );
885        assert!(
886            sb_bounds.height > 0.0,
887            "scrollbar should have non-zero height"
888        );
889
890        // Press in the middle of the thumb (thumb spans y=sb_bounds.y..+half).
891        let thumb_cx = sb_bounds.x + sb_bounds.width / 2.0;
892        let thumb_cy = sb_bounds.y + sb_bounds.height / 4.0;
893        tree.pointer_move(Point::new(thumb_cx, thumb_cy));
894        tree.dispatch_event(WidgetEvent::PointerDown {
895            position: Point::new(thumb_cx, thumb_cy),
896            button: PointerButton::Primary,
897            modifiers: Modifiers::NONE,
898        });
899
900        // Cross the drag threshold…
901        tree.dispatch_event(WidgetEvent::PointerMove {
902            position: Point::new(thumb_cx, thumb_cy + 10.0),
903        });
904        // …and then actually drag down.
905        tree.dispatch_event(WidgetEvent::PointerMove {
906            position: Point::new(thumb_cx, thumb_cy + 100.0),
907        });
908        tree.dispatch_event(WidgetEvent::PointerUp {
909            position: Point::new(thumb_cx, thumb_cy + 100.0),
910            button: PointerButton::Primary,
911            modifiers: Modifiers::NONE,
912        });
913
914        // Apply the scroll-triggered relayout so the content's cached
915        // bounds reflect the new scroll offset (the real event loop does
916        // this automatically every frame).
917        tree.layout(SizeProposal::exact(400.0, 400.0));
918
919        // The scroll position should have advanced by a substantial amount
920        // (a 100-px drag on a 400-px track with 50 % viewport ratio moves
921        // the content ~200 px).
922        let final_scroll = tree.hit_test(Point::new(1.0, 1.0)); // dummy, just keep borrow checker quiet
923        let _ = final_scroll;
924        // We can't read scroll_y directly from the public API; assert the
925        // *bounds* of the content child moved in the ScrollArea's layout
926        // rect — after layout the content's origin.y is `-scroll_y`.
927        let content_bounds = tree.bounds(tree.children(root)[0]);
928        assert!(
929            content_bounds.y < -1.0,
930            "content should have scrolled up (y < 0); got y={}",
931            content_bounds.y
932        );
933    }
934
935    #[test]
936    fn drag_release_outside_does_not_stick() {
937        // Regression test: dragging the thumb and releasing outside the
938        // scrollbar must not leave `dragging` stuck to true. This requires
939        // pointer capture so that PointerUp reaches the scrollbar even when
940        // the pointer is outside its bounds.
941        let (bar, position, ..) = make_scrollbar();
942        let mut tree = WidgetTree::new();
943        let _id = tree.add(bar);
944        tree.layout(SizeProposal::exact(12.0, 400.0));
945        tree.render();
946
947        // Start drag on the thumb
948        tree.pointer_move(Point::new(6.0, 10.0));
949        tree.dispatch_event(WidgetEvent::PointerDown {
950            position: Point::new(6.0, 10.0),
951            button: PointerButton::Primary,
952            modifiers: teksilo_core::event::Modifiers::NONE,
953        });
954
955        // Move far outside the scrollbar bounds
956        tree.dispatch_event(WidgetEvent::PointerMove {
957            position: Point::new(200.0, 300.0),
958        });
959
960        // Release outside
961        tree.dispatch_event(WidgetEvent::PointerUp {
962            position: Point::new(200.0, 300.0),
963            button: PointerButton::Primary,
964            modifiers: teksilo_core::event::Modifiers::NONE,
965        });
966
967        // Now hover the scrollbar again — should NOT continue dragging
968        let pos_before = position.get();
969        tree.pointer_move(Point::new(6.0, 50.0));
970        tree.dispatch_event(WidgetEvent::PointerMove {
971            position: Point::new(6.0, 50.0),
972        });
973
974        let pos_after = position.get();
975        assert!(
976            (pos_after - pos_before).abs() < 0.01,
977            "Hovering after release should not move scroll: before={}, after={}",
978            pos_before,
979            pos_after,
980        );
981    }
982}