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            handlers = handlers.on_key(move |event, _ctx| {
477                let max = max_scroll.get();
478                if max <= 0.0 {
479                    return EventResponse::Ignored;
480                }
481                match event {
482                    WidgetEvent::KeyDown { key, .. } => {
483                        use teksilo_core::event::Key;
484                        let step = step_size;
485                        match (orientation, key) {
486                            (ScrollBarOrientation::Vertical, Key::ArrowUp) => {
487                                set_scroll(scroll_position.get() - step);
488                                EventResponse::Handled
489                            }
490                            (ScrollBarOrientation::Vertical, Key::ArrowDown) => {
491                                set_scroll(scroll_position.get() + step);
492                                EventResponse::Handled
493                            }
494                            (ScrollBarOrientation::Horizontal, Key::ArrowLeft) => {
495                                set_scroll(scroll_position.get() - step);
496                                EventResponse::Handled
497                            }
498                            (ScrollBarOrientation::Horizontal, Key::ArrowRight) => {
499                                set_scroll(scroll_position.get() + step);
500                                EventResponse::Handled
501                            }
502                            (_, Key::Home) => {
503                                set_scroll(0.0);
504                                EventResponse::Handled
505                            }
506                            (_, Key::End) => {
507                                set_scroll(max);
508                                EventResponse::Handled
509                            }
510                            _ => EventResponse::Ignored,
511                        }
512                    }
513                    _ => EventResponse::Ignored,
514                }
515            });
516        }
517
518        // Access action handler
519        {
520            handlers = handlers.on_access_action(move |action, _ctx| {
521                if action == teksilo_core::accesskit::Action::SetValue {
522                    EventResponse::Handled
523                } else {
524                    EventResponse::Ignored
525                }
526            });
527        }
528
529        ctx.apply_self_handlers(handlers);
530
531        vec![body_id]
532    }
533
534    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
535        match self.orientation {
536            ScrollBarOrientation::Vertical => {
537                Size::new(self.thickness, proposal.height.unwrap_or(100.0))
538            }
539            ScrollBarOrientation::Horizontal => {
540                Size::new(proposal.width.unwrap_or(100.0), self.thickness)
541            }
542        }
543        .into()
544    }
545
546    fn place_children(
547        &self,
548        bounds: Rect,
549        _proposal: SizeProposal,
550        children: &mut [WidgetPlacement],
551        _ctx: &LayoutContext,
552    ) {
553        // Cache bounds for event handling (drag/tap hit-tests against
554        // `self.thumb_rect()`, which reads `cached_bounds`).
555        self.cached_bounds.set(bounds);
556        for child in children.iter_mut() {
557            child.origin = bounds.origin();
558            child.size = bounds.size();
559        }
560    }
561
562    fn children(&self) -> Vec<WidgetId> {
563        self.body_id.into_iter().collect()
564    }
565
566    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
567        // Scrollbars are pointer UI. AT uses ScrollUp/Down/Left/Right on the
568        // parent ScrollView node — exposing the bar itself adds noise without
569        // benefit and creates spurious Tab stops in screen readers.
570        builder.set_hidden();
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use teksilo_canvas::SizeProposal;
578    use teksilo_core::widget_tree::WidgetTree;
579
580    // A `thumb_color` override must reach the `ScrollBarStyleConfig` the active
581    // style sees, so a custom style (or the recipe) can tint the thumb. Mirrors
582    // how `Button::text_role` flows into `ButtonStyleConfig`.
583    #[test]
584    fn thumb_color_override_threads_into_style_config() {
585        use std::cell::Cell;
586        use std::rc::Rc;
587        use teksilo_core::build_context::BuildContext;
588        use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
589
590        struct RecordingStyle(Rc<Cell<bool>>);
591        impl ScrollBarStyle for RecordingStyle {
592            fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
593                self.0.set(cfg.thumb_color.is_some());
594                ctx.add(crate::primitives::Spacer::new())
595            }
596        }
597
598        let saw_override = Rc::new(Cell::new(false));
599        let mut tree = WidgetTree::new();
600        let bar = ScrollBar::new(
601            ScrollBarOrientation::Vertical,
602            Signal::new(0.0),
603            Signal::new(500.0),
604            Signal::new(0.5),
605        )
606        .style(RecordingStyle(saw_override.clone()))
607        .thumb_color(teksilo_tokens::TextRole::TooltipText);
608        tree.add(bar);
609        tree.layout(SizeProposal::exact(20.0, 200.0));
610        assert!(
611            saw_override.get(),
612            "ScrollBar::thumb_color must thread into ScrollBarStyleConfig::thumb_color"
613        );
614    }
615
616    fn make_scrollbar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
617        let position = Signal::new(0.0_f32);
618        let max_scroll = Signal::new(500.0_f32);
619        let viewport_ratio = Signal::new(0.5_f32); // viewport is half of content
620
621        let bar = ScrollBar::new(
622            ScrollBarOrientation::Vertical,
623            position.clone(),
624            max_scroll.clone(),
625            viewport_ratio.clone(),
626        );
627        (bar, position, max_scroll, viewport_ratio)
628    }
629
630    #[test]
631    fn vertical_scrollbar_size() {
632        let (bar, ..) = make_scrollbar();
633        let mut tree = WidgetTree::new();
634        let id = tree.add(bar);
635        tree.layout(SizeProposal {
636            width: None,
637            height: Some(400.0),
638        });
639
640        let bounds = tree.bounds(id);
641        // Vertical: width = thickness (8), height = proposed (400)
642        assert!((bounds.width - 8.0).abs() < 0.01);
643        assert!((bounds.height - 400.0).abs() < 0.01);
644    }
645
646    #[test]
647    fn horizontal_scrollbar_size() {
648        let position = Signal::new(0.0_f32);
649        let max_scroll = Signal::new(500.0_f32);
650        let viewport_ratio = Signal::new(0.5_f32);
651
652        let bar = ScrollBar::new(
653            ScrollBarOrientation::Horizontal,
654            position,
655            max_scroll,
656            viewport_ratio,
657        );
658        let mut tree = WidgetTree::new();
659        let id = tree.add(bar);
660        tree.layout(SizeProposal {
661            width: Some(400.0),
662            height: None,
663        });
664
665        let bounds = tree.bounds(id);
666        // Horizontal: width = proposed (400), height = thickness (8)
667        assert!((bounds.width - 400.0).abs() < 0.01);
668        assert!((bounds.height - 8.0).abs() < 0.01);
669    }
670
671    #[test]
672    fn scrollbar_thumb_drag_updates_position() {
673        let (bar, position, _max, _ratio) = make_scrollbar();
674        let mut tree = WidgetTree::new();
675        let _id = tree.add(bar);
676        tree.layout(SizeProposal::exact(12.0, 400.0));
677
678        // Render once to cache bounds
679        tree.render();
680
681        // Initial position is 0
682        assert!((position.get() - 0.0).abs() < 0.01);
683
684        // Pointer down on the thumb (which starts at top)
685        tree.pointer_move(Point::new(6.0, 10.0));
686        tree.dispatch_event(WidgetEvent::PointerDown {
687            position: Point::new(6.0, 10.0),
688            button: PointerButton::Primary,
689            modifiers: teksilo_core::event::Modifiers::NONE,
690        });
691
692        // Drag 100px down: track is 400px, thumb is 200px (50% ratio),
693        // so available travel = 200px, 100px drag = 50% of travel = 250 scroll.
694        // DragRecognizer needs one move to cross the 5px threshold and emit
695        // DragStarted (which carries the *down* position, so the thumb-vs-track
696        // check latches on), then subsequent moves emit DragMoved with a delta
697        // from the initial press.
698        tree.dispatch_event(WidgetEvent::PointerMove {
699            position: Point::new(6.0, 20.0),
700        });
701        tree.dispatch_event(WidgetEvent::PointerMove {
702            position: Point::new(6.0, 110.0),
703        });
704
705        let pos = position.get();
706        assert!(pos > 200.0, "Expected scroll > 200, got {}", pos);
707        assert!(pos < 300.0, "Expected scroll < 300, got {}", pos);
708    }
709
710    #[test]
711    fn scrollbar_clamps_to_range() {
712        let (bar, position, max_scroll, ..) = make_scrollbar();
713        let mut tree = WidgetTree::new();
714        tree.add(bar);
715        tree.layout(SizeProposal::exact(12.0, 400.0));
716        tree.render();
717
718        // Repeatedly click far below the thumb to page-scroll forward
719        // until we hit the maximum (500). Each page scroll adds 250,
720        // so after 3 clicks the position should be clamped at 500.
721        for _ in 0..5 {
722            tree.pointer_move(Point::new(6.0, 390.0));
723            tree.dispatch_event(WidgetEvent::PointerDown {
724                position: Point::new(6.0, 390.0),
725                button: PointerButton::Primary,
726                modifiers: teksilo_core::event::Modifiers::NONE,
727            });
728            // Release so next click isn't a drag
729            tree.dispatch_event(WidgetEvent::PointerUp {
730                position: Point::new(6.0, 390.0),
731                button: PointerButton::Primary,
732                modifiers: teksilo_core::event::Modifiers::NONE,
733            });
734        }
735
736        let pos = position.get();
737        let max = max_scroll.get();
738        assert!(
739            (pos - max).abs() < 0.01,
740            "Expected pos to be clamped at max={}, got {}",
741            max,
742            pos,
743        );
744    }
745
746    #[test]
747    fn scrollbar_nothing_to_scroll() {
748        let position = Signal::new(0.0_f32);
749        let max_scroll = Signal::new(0.0_f32); // content fits in viewport
750        let viewport_ratio = Signal::new(1.0_f32);
751
752        let bar = ScrollBar::new(
753            ScrollBarOrientation::Vertical,
754            position,
755            max_scroll,
756            viewport_ratio,
757        );
758        let mut tree = WidgetTree::new();
759        tree.add(bar);
760        tree.layout(SizeProposal::exact(12.0, 400.0));
761
762        let frame = tree.render();
763        // When max_scroll is 0, the body's `is_idle` gate suppresses
764        // every paint, so no shapes get queued.
765        assert!(
766            frame.shapes.is_empty(),
767            "Expected no rendering when nothing to scroll"
768        );
769    }
770
771    #[test]
772    fn scrollbar_is_hidden_from_at() {
773        // ScrollBar is a pointer affordance. AT scrolls through the parent
774        // ScrollView's actions, not by navigating the bar directly.
775        let (bar, position, _max_scroll, _ratio) = make_scrollbar();
776        position.set(100.0);
777
778        let mut tree = WidgetTree::new();
779        let id = tree.add(bar);
780        tree.layout(SizeProposal::exact(12.0, 400.0));
781
782        let info = tree.accessibility_node(id);
783        assert!(info.is_hidden(), "ScrollBar must be hidden from AT");
784    }
785
786    #[test]
787    fn track_click_pages_forward() {
788        let (bar, position, ..) = make_scrollbar();
789        let mut tree = WidgetTree::new();
790        let _id = tree.add(bar);
791        tree.layout(SizeProposal::exact(12.0, 400.0));
792        tree.render();
793
794        // Click on the track below the thumb (thumb starts at top, ~200px tall).
795        // Track clicks are routed through `on_tap`, which requires a full
796        // press+release sequence without the pointer crossing the drag
797        // threshold.
798        tree.pointer_move(Point::new(6.0, 350.0));
799        tree.dispatch_event(WidgetEvent::PointerDown {
800            position: Point::new(6.0, 350.0),
801            button: PointerButton::Primary,
802            modifiers: teksilo_core::event::Modifiers::NONE,
803        });
804        tree.dispatch_event(WidgetEvent::PointerUp {
805            position: Point::new(6.0, 350.0),
806            button: PointerButton::Primary,
807            modifiers: teksilo_core::event::Modifiers::NONE,
808        });
809
810        let pos = position.get();
811        assert!(
812            pos > 0.0,
813            "Expected positive scroll after track click, got {}",
814            pos
815        );
816    }
817
818    #[test]
819    fn scrollbar_drag_inside_scroll_area_updates_position() {
820        // Regression: reproduces the real-app case where the ScrollBar
821        // is a child of a ScrollArea (overlay mode), which wraps a tall
822        // content widget. Before the V2 migration this worked through
823        // `on_pointer_event`; the drag must keep working through the
824        // typed `on_drag` + auto-capture path.
825        use crate::primitives::MinSize;
826        use crate::scroll_area::{ScrollArea, ScrollBarMode};
827        use teksilo_canvas::Point;
828        use teksilo_core::event::{Modifiers, PointerButton};
829
830        let mut tree = WidgetTree::new();
831        // Content is twice as tall as the ScrollArea viewport → v scrollbar
832        // is needed with viewport_ratio = 0.5.
833        let content = MinSize::new(400.0, 800.0);
834        let root = tree.add(
835            ScrollArea::new()
836                .child(content)
837                .scroll_bar_style(ScrollBarMode::Permanent),
838        );
839        tree.layout(SizeProposal::exact(400.0, 400.0));
840        tree.render();
841
842        // Find the vertical scrollbar child (second child of ScrollArea:
843        // content is first, v-scrollbar second).
844        let sb_id = tree.children(root)[1];
845        let sb_bounds = tree.bounds(sb_id);
846        assert!(
847            sb_bounds.width > 0.0,
848            "scrollbar should have non-zero width"
849        );
850        assert!(
851            sb_bounds.height > 0.0,
852            "scrollbar should have non-zero height"
853        );
854
855        // Press in the middle of the thumb (thumb spans y=sb_bounds.y..+half).
856        let thumb_cx = sb_bounds.x + sb_bounds.width / 2.0;
857        let thumb_cy = sb_bounds.y + sb_bounds.height / 4.0;
858        tree.pointer_move(Point::new(thumb_cx, thumb_cy));
859        tree.dispatch_event(WidgetEvent::PointerDown {
860            position: Point::new(thumb_cx, thumb_cy),
861            button: PointerButton::Primary,
862            modifiers: Modifiers::NONE,
863        });
864
865        // Cross the drag threshold…
866        tree.dispatch_event(WidgetEvent::PointerMove {
867            position: Point::new(thumb_cx, thumb_cy + 10.0),
868        });
869        // …and then actually drag down.
870        tree.dispatch_event(WidgetEvent::PointerMove {
871            position: Point::new(thumb_cx, thumb_cy + 100.0),
872        });
873        tree.dispatch_event(WidgetEvent::PointerUp {
874            position: Point::new(thumb_cx, thumb_cy + 100.0),
875            button: PointerButton::Primary,
876            modifiers: Modifiers::NONE,
877        });
878
879        // Apply the scroll-triggered relayout so the content's cached
880        // bounds reflect the new scroll offset (the real event loop does
881        // this automatically every frame).
882        tree.layout(SizeProposal::exact(400.0, 400.0));
883
884        // The scroll position should have advanced by a substantial amount
885        // (a 100-px drag on a 400-px track with 50 % viewport ratio moves
886        // the content ~200 px).
887        let final_scroll = tree.hit_test(Point::new(1.0, 1.0)); // dummy, just keep borrow checker quiet
888        let _ = final_scroll;
889        // We can't read scroll_y directly from the public API; assert the
890        // *bounds* of the content child moved in the ScrollArea's layout
891        // rect — after layout the content's origin.y is `-scroll_y`.
892        let content_bounds = tree.bounds(tree.children(root)[0]);
893        assert!(
894            content_bounds.y < -1.0,
895            "content should have scrolled up (y < 0); got y={}",
896            content_bounds.y
897        );
898    }
899
900    #[test]
901    fn drag_release_outside_does_not_stick() {
902        // Regression test: dragging the thumb and releasing outside the
903        // scrollbar must not leave `dragging` stuck to true. This requires
904        // pointer capture so that PointerUp reaches the scrollbar even when
905        // the pointer is outside its bounds.
906        let (bar, position, ..) = make_scrollbar();
907        let mut tree = WidgetTree::new();
908        let _id = tree.add(bar);
909        tree.layout(SizeProposal::exact(12.0, 400.0));
910        tree.render();
911
912        // Start drag on the thumb
913        tree.pointer_move(Point::new(6.0, 10.0));
914        tree.dispatch_event(WidgetEvent::PointerDown {
915            position: Point::new(6.0, 10.0),
916            button: PointerButton::Primary,
917            modifiers: teksilo_core::event::Modifiers::NONE,
918        });
919
920        // Move far outside the scrollbar bounds
921        tree.dispatch_event(WidgetEvent::PointerMove {
922            position: Point::new(200.0, 300.0),
923        });
924
925        // Release outside
926        tree.dispatch_event(WidgetEvent::PointerUp {
927            position: Point::new(200.0, 300.0),
928            button: PointerButton::Primary,
929            modifiers: teksilo_core::event::Modifiers::NONE,
930        });
931
932        // Now hover the scrollbar again — should NOT continue dragging
933        let pos_before = position.get();
934        tree.pointer_move(Point::new(6.0, 50.0));
935        tree.dispatch_event(WidgetEvent::PointerMove {
936            position: Point::new(6.0, 50.0),
937        });
938
939        let pos_after = position.get();
940        assert!(
941            (pos_after - pos_before).abs() < 0.01,
942            "Hovering after release should not move scroll: before={}, after={}",
943            pos_before,
944            pos_after,
945        );
946    }
947}