Skip to main content

teksilo_scene/
scroll_view.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`SceneScrollView`] — a thin composite that gives a [`SceneView`] draggable
5//! scroll bars, mirroring the widget-tier
6//! [`ScrollArea`](teksilo_widgets::ScrollArea)'s options: the same
7//! [`ScrollBarMode`] (Overlay / Permanent / Thin, with its Tier-3
8//! `ScrollBarStyle`), per-axis [`ScrollBarPolicy`] (AsNeeded / AlwaysOn /
9//! AlwaysOff), and thickness. Smooth wheel / keyboard panning and the
10//! overscroll policy stay configured on the wrapped `SceneView` itself (it
11//! already animates pan and honours reduced-motion); the scroll bars simply
12//! track that motion.
13//!
14//! ## Why a wrapper
15//!
16//! A `SceneView` wraps its **entire child subtree** in the pan/zoom view
17//! transform (via `set_content_transform`), so scroll bars added as its own
18//! children would pan and zoom along with the content. Instead — exactly like
19//! `ScrollArea` wraps arbitrary content and `SceneMinimap` is a sibling overlay
20//! — this widget hosts the `SceneView` as content plus two reusable
21//! [`ScrollBar`] children *outside* the transform,
22//! and bridges the bars' scroll signals to the view's `pan_x`/`pan_y`.
23//!
24//! ## How the bridge works
25//!
26//! The scene's scrollable extent is its **effective pan bounds** (the
27//! `Scene`-declared `pan_bounds` intersected with any view-level
28//! `pan_bounds_override`), falling back to the union of item bounds. With the
29//! standard view transform `screen = zoom*scene + pan + bounds_origin` and the
30//! `SceneView` placed flush at this widget's origin (so `bounds_origin` cancels
31//! the viewport's screen offset), the per-axis mapping in **screen-pixel
32//! units** is:
33//!
34//! ```text
35//! scroll_pos_x   = -pan_x - extent.x * zoom
36//! max_scroll_x   = (extent.width * zoom - viewport_width).max(0)
37//! viewport_ratio = viewport_width / (extent.width * zoom)
38//! ```
39//!
40//! and the inverse, when a bar writes a new `scroll_pos_x`:
41//!
42//! ```text
43//! pan_x = -extent.x * zoom - scroll_pos_x
44//! ```
45//!
46//! The display direction (camera → bar metrics) is recomputed each
47//! `place_children` — the same place `ScrollArea` computes its metrics — so it
48//! never lags a layout pass. The interaction direction (bar drag → pan) is a
49//! pair of guarded effects, one per axis, that snap the pan **immediately** so
50//! the thumb tracks the cursor 1:1 (the desktop scroll-bar convention). Both
51//! use an epsilon equality guard (the `color_picker` bidirectional-bridge
52//! idiom) so a write arriving from the opposite direction is a no-op and the
53//! loop closes — in particular the bars track the `SceneView`'s own smooth
54//! wheel / keyboard pan animation without fighting it.
55//!
56//! Rotation is supported but **approximate**: the mapping is exact only when
57//! `rotation == 0`; while rotated the thumbs track the camera using the
58//! axis-aligned formula above.
59
60use teksilo_canvas::{Point, Rect, Size, SizeProposal};
61use teksilo_core::accessibility::AccessNodeBuilder;
62use teksilo_core::binding::BindingLevel;
63use teksilo_core::build_context::BuildContext;
64use teksilo_core::signal::Signal;
65use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
66use teksilo_core::widget_id::WidgetId;
67
68use teksilo_widgets::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
69pub use teksilo_widgets::{ScrollBarMode, ScrollBarPolicy};
70
71use crate::scene::PanAxes;
72use crate::scene_model::SceneModel;
73use crate::view::SceneView;
74
75/// Resolve the scrollable extent (scene coords): the effective pan bounds
76/// (Scene-declared `pan_bounds` intersected with the view-level override —
77/// tightening-only, falling back to either side alone), and finally to the
78/// union of all item rects when no bounds are declared.
79fn effective_extent(model: &SceneModel, override_bounds: Option<Rect>) -> Option<Rect> {
80    let scene_bounds = model.current_pan_bounds();
81    let merged = match (scene_bounds, override_bounds) {
82        (None, None) => None,
83        (Some(r), None) | (None, Some(r)) => Some(r),
84        (Some(a), Some(b)) => Some(intersect_rect(a, b)),
85    };
86    merged.or_else(|| model.0.borrow().scene_rect_extent())
87}
88
89/// Intersection of two rects; falls back to `a` when they don't overlap
90/// (mirrors `SceneView`'s `intersect_pan_bounds`: a non-overlapping override
91/// can't loosen the Scene-declared bounds, so keep the declared one).
92fn intersect_rect(a: Rect, b: Rect) -> Rect {
93    let x = a.x.max(b.x);
94    let y = a.y.max(b.y);
95    let right = a.right().min(b.right());
96    let bottom = a.bottom().min(b.bottom());
97    if right > x && bottom > y {
98        Rect::new(x, y, right - x, bottom - y)
99    } else {
100        a
101    }
102}
103
104/// Per-axis scroll metrics in screen-pixel units.
105#[derive(Clone, Copy)]
106struct AxisMetrics {
107    max_x: f32,
108    max_y: f32,
109    ratio_x: f32,
110    ratio_y: f32,
111    pos_x: f32,
112    pos_y: f32,
113}
114
115/// A [`SceneView`] with draggable scroll bars.
116///
117/// Construct directly from a configured view, or via the
118/// [`SceneView::with_scroll_bars`] convenience method:
119///
120/// ```rust
121/// # use teksilo_scene::{Scene, SceneView, SceneScrollView, ScrollBarMode};
122/// let scrollable = SceneView::new(Scene::new())
123///     .with_scroll_bars()
124///     .scroll_bar_mode(ScrollBarMode::Overlay);
125/// # let _ = scrollable;
126/// ```
127pub struct SceneScrollView {
128    /// The wrapped view, moved into the arena on first build.
129    scene_view: Option<Box<SceneView>>,
130    scene_view_id: Option<WidgetId>,
131
132    // --- signals captured from the SceneView before it is moved in ---
133    pan_x: Signal<f32>,
134    pan_y: Signal<f32>,
135    zoom: Signal<f32>,
136    model: SceneModel,
137    pan_bounds_override: Signal<Option<Rect>>,
138
139    // --- owned bridge signals (screen-pixel units) ---
140    /// Horizontal scroll position; read by the h-bar, written by both the bar
141    /// drag and the display recompute in `place_children`.
142    scroll_pos_x: Signal<f32>,
143    scroll_pos_y: Signal<f32>,
144    max_scroll_x: Signal<f32>,
145    max_scroll_y: Signal<f32>,
146    viewport_ratio_x: Signal<f32>,
147    viewport_ratio_y: Signal<f32>,
148
149    /// Resolved children: `[scene_view, v_scrollbar, h_scrollbar]`.
150    child_ids: Vec<WidgetId>,
151
152    // --- configuration (mirrors ScrollArea) ---
153    scroll_bar_mode: ScrollBarMode,
154    vertical_policy: ScrollBarPolicy,
155    horizontal_policy: ScrollBarPolicy,
156    scroll_bar_thickness: f32,
157}
158
159impl std::fmt::Debug for SceneScrollView {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct("SceneScrollView")
162            .field("mode", &self.scroll_bar_mode)
163            .field("v_policy", &self.vertical_policy)
164            .field("h_policy", &self.horizontal_policy)
165            .field("max_scroll_x", &self.max_scroll_x.get())
166            .field("max_scroll_y", &self.max_scroll_y.get())
167            .finish()
168    }
169}
170
171impl SceneScrollView {
172    /// Wrap a configured [`SceneView`] in a scroll-bar host. Captures the
173    /// view's pan/zoom/model signals before moving it into the arena.
174    pub fn new(view: SceneView) -> Self {
175        let pan_x = view.pan_x_signal();
176        let pan_y = view.pan_y_signal();
177        let zoom = view.zoom_signal();
178        let model = view.model();
179        let pan_bounds_override = view.pan_bounds_override_signal();
180
181        Self {
182            scene_view: Some(Box::new(view)),
183            scene_view_id: None,
184            pan_x,
185            pan_y,
186            zoom,
187            model,
188            pan_bounds_override,
189            scroll_pos_x: Signal::new(0.0),
190            scroll_pos_y: Signal::new(0.0),
191            max_scroll_x: Signal::new(0.0),
192            max_scroll_y: Signal::new(0.0),
193            viewport_ratio_x: Signal::new(1.0),
194            viewport_ratio_y: Signal::new(1.0),
195            child_ids: Vec::new(),
196            scroll_bar_mode: ScrollBarMode::default(),
197            vertical_policy: ScrollBarPolicy::default(),
198            horizontal_policy: ScrollBarPolicy::default(),
199            scroll_bar_thickness: 12.0,
200        }
201    }
202
203    /// Set the scroll-bar display mode (Overlay / Permanent / Thin).
204    pub fn scroll_bar_mode(mut self, mode: ScrollBarMode) -> Self {
205        self.scroll_bar_mode = mode;
206        self
207    }
208
209    /// Set the vertical scroll-bar visibility policy.
210    pub fn vertical_policy(mut self, policy: ScrollBarPolicy) -> Self {
211        self.vertical_policy = policy;
212        self
213    }
214
215    /// Set the horizontal scroll-bar visibility policy.
216    pub fn horizontal_policy(mut self, policy: ScrollBarPolicy) -> Self {
217        self.horizontal_policy = policy;
218        self
219    }
220
221    /// Set the scroll-bar thickness (and the gutter width in Permanent mode).
222    pub fn scroll_bar_thickness(mut self, thickness: f32) -> Self {
223        self.scroll_bar_thickness = thickness.max(0.0);
224        self
225    }
226
227    /// Horizontal scroll position signal (screen-pixel units), for external
228    /// observation. `0` = content's leading edge flush with the viewport.
229    pub fn scroll_pos_x_signal(&self) -> &Signal<f32> {
230        &self.scroll_pos_x
231    }
232
233    /// Vertical scroll position signal (screen-pixel units).
234    pub fn scroll_pos_y_signal(&self) -> &Signal<f32> {
235        &self.scroll_pos_y
236    }
237
238    /// Maximum horizontal scroll offset (`extent.width*zoom - viewport_width`,
239    /// or 0 when the content fits). Bind for "is there more to scroll?" chrome.
240    pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
241        &self.max_scroll_x
242    }
243
244    /// Maximum vertical scroll offset.
245    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
246        &self.max_scroll_y
247    }
248
249    /// Horizontal viewport/content ratio (0.0..1.0) — the relative thumb size.
250    pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
251        &self.viewport_ratio_x
252    }
253
254    /// Vertical viewport/content ratio (0.0..1.0).
255    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
256        &self.viewport_ratio_y
257    }
258
259    /// Compute the per-axis scroll metrics for a given viewport size, from the
260    /// current pan / zoom / extent. Returns all-zero (ratios 1.0) when there is
261    /// no usable extent, which collapses `AsNeeded` bars.
262    fn metrics(&self, viewport_w: f32, viewport_h: f32) -> AxisMetrics {
263        let zoom = self.zoom.get();
264        let extent = effective_extent(&self.model, self.pan_bounds_override.get());
265        match extent {
266            Some(e) if e.width > 0.0 && e.height > 0.0 && zoom > 0.0 => {
267                let content_w = e.width * zoom;
268                let content_h = e.height * zoom;
269                let max_x = (content_w - viewport_w).max(0.0);
270                let max_y = (content_h - viewport_h).max(0.0);
271                let ratio_x = (viewport_w / content_w).clamp(0.0, 1.0);
272                let ratio_y = (viewport_h / content_h).clamp(0.0, 1.0);
273                let pos_x = (-self.pan_x.get() - e.x * zoom).clamp(0.0, max_x);
274                let pos_y = (-self.pan_y.get() - e.y * zoom).clamp(0.0, max_y);
275                AxisMetrics {
276                    max_x,
277                    max_y,
278                    ratio_x,
279                    ratio_y,
280                    pos_x,
281                    pos_y,
282                }
283            }
284            _ => AxisMetrics {
285                max_x: 0.0,
286                max_y: 0.0,
287                ratio_x: 1.0,
288                ratio_y: 1.0,
289                pos_x: 0.0,
290                pos_y: 0.0,
291            },
292        }
293    }
294
295    /// Register the guarded `scroll_pos → pan` effect for one axis. `is_x`
296    /// selects the X (true) or Y (false) axis. Re-installed every build (effect
297    /// handles are dropped on rebuild).
298    fn register_pan_effect(&self, ctx: &mut BuildContext, is_x: bool) {
299        let scroll_pos = if is_x {
300            self.scroll_pos_x.clone()
301        } else {
302            self.scroll_pos_y.clone()
303        };
304        let pan = if is_x {
305            self.pan_x.clone()
306        } else {
307            self.pan_y.clone()
308        };
309        let zoom = self.zoom.clone();
310        let model = self.model.clone();
311        let pan_bounds_override = self.pan_bounds_override.clone();
312
313        ctx.effect(&scroll_pos, move |new_pos| {
314            // Respect the scene's pan-axes policy: a locked axis never pans,
315            // even if its (hidden) metrics still report overflow.
316            let axes = model.current_pan_axes();
317            let allowed = if is_x {
318                matches!(axes, PanAxes::Horizontal | PanAxes::Both)
319            } else {
320                matches!(axes, PanAxes::Vertical | PanAxes::Both)
321            };
322            if !allowed {
323                return;
324            }
325            let Some(extent) = effective_extent(&model, pan_bounds_override.get()) else {
326                return;
327            };
328            let z = zoom.get();
329            if z <= 0.0 {
330                return;
331            }
332            let extent_origin = if is_x { extent.x } else { extent.y };
333            let target_pan = -extent_origin * z - *new_pos;
334            // Guard: skip the write that the display recompute already
335            // reflected (the value came from `place_children` tracking the
336            // camera, not from a drag/click on the bar).
337            let implied = -pan.get() - extent_origin * z;
338            if (implied - *new_pos).abs() < 0.5 {
339                return;
340            }
341            // Snap, not animate: the thumb must track the cursor 1:1, and an
342            // animated pan would fight the per-frame display recompute.
343            pan.set(target_pan);
344        });
345    }
346}
347
348/// Decide whether a scroll bar is shown given its policy, axis permission, and
349/// current overflow.
350fn resolve_show(policy: ScrollBarPolicy, axis_allowed: bool, max: f32) -> bool {
351    axis_allowed
352        && match policy {
353            ScrollBarPolicy::AlwaysOn => true,
354            ScrollBarPolicy::AlwaysOff => false,
355            ScrollBarPolicy::AsNeeded => max > 0.0,
356        }
357}
358
359impl Widget for SceneScrollView {
360    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
361        // First build only: move the view in and create the bars.
362        if self.child_ids.is_empty() {
363            let view = self
364                .scene_view
365                .take()
366                .expect("SceneScrollView: SceneView already consumed");
367            let sv_id = ctx.add(*view);
368            self.scene_view_id = Some(sv_id);
369
370            let visual = match self.scroll_bar_mode {
371                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
372                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
373                ScrollBarMode::Thin => ScrollBarVisual::Thin,
374            };
375
376            let v_bar = ScrollBar::new(
377                ScrollBarOrientation::Vertical,
378                self.scroll_pos_y.clone(),
379                self.max_scroll_y.clone(),
380                self.viewport_ratio_y.clone(),
381            )
382            .thickness(self.scroll_bar_thickness)
383            .visual(visual);
384            let v_id = ctx.add(v_bar);
385
386            let h_bar = ScrollBar::new(
387                ScrollBarOrientation::Horizontal,
388                self.scroll_pos_x.clone(),
389                self.max_scroll_x.clone(),
390                self.viewport_ratio_x.clone(),
391            )
392            .thickness(self.scroll_bar_thickness)
393            .visual(visual);
394            let h_id = ctx.add(h_bar);
395
396            self.child_ids = vec![sv_id, v_id, h_id];
397
398            // Any camera or extent change must re-run `place_children` so the
399            // metrics (and `AsNeeded` visibility) refresh. The bars' own thumb
400            // bindings are RepaintOnly; this drives the layout side.
401            let self_id = ctx.self_id();
402            let registry = ctx.binding_registry();
403            self.pan_x
404                .bind_to(self_id, registry, BindingLevel::Relayout);
405            self.pan_y
406                .bind_to(self_id, registry, BindingLevel::Relayout);
407            self.zoom.bind_to(self_id, registry, BindingLevel::Relayout);
408            self.model
409                .pan_bounds_signal()
410                .bind_to(self_id, registry, BindingLevel::Relayout);
411            // `place_children`/`metrics` also read the pan axes (gates the
412            // AsNeeded show/hide) and the view-level pan-bounds override (feeds
413            // `effective_extent`). Both are runtime-mutable, so bind them too —
414            // otherwise mutating either leaves max_scroll_*/viewport_ratio_*
415            // stale until an unrelated relayout fires.
416            self.model
417                .pan_axes_signal()
418                .bind_to(self_id, registry, BindingLevel::Relayout);
419            self.pan_bounds_override
420                .bind_to(self_id, registry, BindingLevel::Relayout);
421        }
422
423        // Always (re)register the bar→pan effects — handles are dropped on
424        // rebuild.
425        self.register_pan_effect(ctx, true);
426        self.register_pan_effect(ctx, false);
427
428        self.child_ids.clone()
429    }
430
431    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
432        // Delegate to the SceneView, which fills the proposed area.
433        self.scene_view_id
434            .and_then(|id| ctx.child_size(id, proposal))
435            .map(LayoutResponse::from)
436            .unwrap_or_else(|| proposal.resolve(800.0, 600.0).into())
437    }
438
439    fn place_children(
440        &self,
441        bounds: Rect,
442        _proposal: SizeProposal,
443        children: &mut [WidgetPlacement],
444        ctx: &LayoutContext,
445    ) {
446        if children.len() < 3 {
447            return;
448        }
449
450        let axes = self.model.current_pan_axes();
451        let h_allowed = matches!(axes, PanAxes::Horizontal | PanAxes::Both);
452        let v_allowed = matches!(axes, PanAxes::Vertical | PanAxes::Both);
453        let sb = self.scroll_bar_thickness;
454        let permanent = self.scroll_bar_mode == ScrollBarMode::Permanent;
455
456        // Pass 1: decide visibility from metrics against the full bounds, so a
457        // Permanent gutter reservation can be computed.
458        let m1 = self.metrics(bounds.width, bounds.height);
459        let show_v1 = resolve_show(self.vertical_policy, v_allowed, m1.max_y);
460        let show_h1 = resolve_show(self.horizontal_policy, h_allowed, m1.max_x);
461        let v_reserved = if permanent && show_v1 { sb } else { 0.0 };
462        let h_reserved = if permanent && show_h1 { sb } else { 0.0 };
463
464        // Pass 2: final metrics against the reserved viewport.
465        let viewport_w = (bounds.width - v_reserved).max(0.0);
466        let viewport_h = (bounds.height - h_reserved).max(0.0);
467        let m = self.metrics(viewport_w, viewport_h);
468        let show_v = resolve_show(self.vertical_policy, v_allowed, m.max_y);
469        let show_h = resolve_show(self.horizontal_policy, h_allowed, m.max_x);
470
471        // Publish metrics (guarded). Writing `scroll_pos_*` triggers the
472        // bar→pan effect, whose guard absorbs the round-trip.
473        self.max_scroll_x.set_if_changed(m.max_x);
474        self.max_scroll_y.set_if_changed(m.max_y);
475        self.viewport_ratio_x.set_if_changed(m.ratio_x);
476        self.viewport_ratio_y.set_if_changed(m.ratio_y);
477        self.scroll_pos_x.set_if_changed(m.pos_x);
478        self.scroll_pos_y.set_if_changed(m.pos_y);
479
480        // Place the SceneView filling the (possibly gutter-reduced) area.
481        // A reserved vertical gutter sits on the right in LTR but on the left
482        // in RTL (see the vertical bar placement below), so in RTL the content
483        // must start `v_reserved` to the right or it overlaps the bar. The
484        // horizontal gutter is always at the bottom, so `y` never shifts.
485        let content_x = if ctx.is_rtl() {
486            bounds.x + v_reserved
487        } else {
488            bounds.x
489        };
490        children[0].origin = Point::new(content_x, bounds.y);
491        children[0].size = Size::new(viewport_w, viewport_h);
492
493        // Vertical scroll bar.
494        if show_v {
495            let sb_x = if ctx.is_rtl() {
496                bounds.x
497            } else {
498                bounds.right() - sb
499            };
500            let sb_h = if h_reserved > 0.0 || (!permanent && show_h) {
501                bounds.height - sb
502            } else {
503                bounds.height
504            };
505            children[1].origin = Point::new(sb_x, bounds.y);
506            children[1].size = Size::new(sb, sb_h);
507        } else {
508            children[1].origin = bounds.origin();
509            children[1].size = Size::ZERO;
510        }
511
512        // Horizontal scroll bar.
513        if show_h {
514            let sb_y = bounds.bottom() - sb;
515            let sb_x = if ctx.is_rtl() && v_reserved > 0.0 {
516                bounds.x + sb
517            } else {
518                bounds.x
519            };
520            let sb_w = if v_reserved > 0.0 || (!permanent && show_v) {
521                bounds.width - sb
522            } else {
523                bounds.width
524            };
525            children[2].origin = Point::new(sb_x, sb_y);
526            children[2].size = Size::new(sb_w, sb);
527        } else {
528            children[2].origin = bounds.origin();
529            children[2].size = Size::ZERO;
530        }
531    }
532
533    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
534        // The SceneView and the ScrollBar children paint themselves.
535    }
536
537    fn children(&self) -> Vec<WidgetId> {
538        self.child_ids.clone()
539    }
540
541    fn clips_children(&self) -> bool {
542        true
543    }
544
545    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
546        // Transparent grouping container: the SceneView owns the real scene AT
547        // tree, and the ScrollBar children hide themselves from AT. Claiming
548        // Role::ScrollView here would add a redundant node above the scene.
549        builder.set_role(teksilo_core::accesskit::Role::Group);
550        builder.inner_mut().set_clips_children();
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::Scene;
558    use teksilo_core::widget_tree::WidgetTree;
559
560    /// A bounded scene of `extent` size, wrapped in a `SceneScrollView`.
561    fn bounded(extent: Rect, axes: PanAxes, mode: ScrollBarMode) -> SceneScrollView {
562        let mut scene = Scene::new();
563        scene.set_pan_bounds(Some(extent));
564        scene.pan_axes(axes);
565        SceneView::new(scene)
566            .with_scroll_bars()
567            .scroll_bar_mode(mode)
568    }
569
570    #[test]
571    fn metrics_from_known_extent() {
572        let mut tree = WidgetTree::new();
573        let scrollable = bounded(
574            Rect::new(0.0, 0.0, 800.0, 600.0),
575            PanAxes::Both,
576            ScrollBarMode::Overlay,
577        );
578        let max_x = scrollable.max_scroll_x_signal().clone();
579        let max_y = scrollable.max_scroll_y_signal().clone();
580        let ratio_x = scrollable.viewport_ratio_x_signal().clone();
581        let pos_x = scrollable.scroll_pos_x_signal().clone();
582
583        let _id = tree.add(scrollable);
584        tree.layout(SizeProposal::exact(400.0, 300.0));
585
586        assert!((max_x.get() - 400.0).abs() < 0.5, "max_x = {}", max_x.get());
587        assert!((max_y.get() - 300.0).abs() < 0.5, "max_y = {}", max_y.get());
588        assert!(
589            (ratio_x.get() - 0.5).abs() < 0.01,
590            "ratio_x = {}",
591            ratio_x.get()
592        );
593        assert!(pos_x.get().abs() < 0.5, "pos_x = {}", pos_x.get());
594    }
595
596    #[test]
597    fn as_needed_hides_bar_when_content_fits() {
598        // Extent smaller than the viewport → nothing to scroll.
599        let mut tree = WidgetTree::new();
600        let scrollable = bounded(
601            Rect::new(0.0, 0.0, 200.0, 200.0),
602            PanAxes::Both,
603            ScrollBarMode::Overlay,
604        );
605        let id = tree.add(scrollable);
606        tree.layout(SizeProposal::exact(400.0, 300.0));
607
608        let children = tree.children(id);
609        assert_eq!(children.len(), 3);
610        let v_sb = tree.bounds(children[1]);
611        assert!(
612            v_sb.width.abs() < 0.01 && v_sb.height.abs() < 0.01,
613            "v_sb = {:?}",
614            v_sb
615        );
616        let h_sb = tree.bounds(children[2]);
617        assert!(
618            h_sb.width.abs() < 0.01 && h_sb.height.abs() < 0.01,
619            "h_sb = {:?}",
620            h_sb
621        );
622    }
623
624    #[test]
625    fn permanent_mode_reserves_gutter() {
626        let mut tree = WidgetTree::new();
627        let scrollable = bounded(
628            Rect::new(0.0, 0.0, 800.0, 600.0),
629            PanAxes::Both,
630            ScrollBarMode::Permanent,
631        )
632        .scroll_bar_thickness(12.0);
633        let id = tree.add(scrollable);
634        tree.layout(SizeProposal::exact(400.0, 300.0));
635
636        let children = tree.children(id);
637        let scene_view = tree.bounds(children[0]);
638        assert!(
639            (scene_view.width - 388.0).abs() < 0.5,
640            "scene view width = {}",
641            scene_view.width
642        );
643        assert!(
644            (scene_view.height - 288.0).abs() < 0.5,
645            "scene view height = {}",
646            scene_view.height
647        );
648    }
649
650    #[test]
651    fn pan_axes_horizontal_hides_vertical_bar() {
652        // Content overflows both axes, but only horizontal pan is allowed.
653        let mut tree = WidgetTree::new();
654        let scrollable = bounded(
655            Rect::new(0.0, 0.0, 800.0, 600.0),
656            PanAxes::Horizontal,
657            ScrollBarMode::Overlay,
658        );
659        let id = tree.add(scrollable);
660        tree.layout(SizeProposal::exact(400.0, 300.0));
661
662        let children = tree.children(id);
663        let v_sb = tree.bounds(children[1]);
664        assert!(
665            v_sb.width.abs() < 0.01 && v_sb.height.abs() < 0.01,
666            "vertical bar should be hidden when axis locked, got {:?}",
667            v_sb
668        );
669        // Horizontal bar still present.
670        let h_sb = tree.bounds(children[2]);
671        assert!(
672            h_sb.width > 0.0,
673            "horizontal bar should be visible, got {:?}",
674            h_sb
675        );
676    }
677
678    #[test]
679    fn zoom_changes_max_scroll() {
680        let mut scene = Scene::new();
681        scene.set_pan_bounds(Some(Rect::new(0.0, 0.0, 800.0, 600.0)));
682        let view = SceneView::new(scene);
683        let zoom = view.zoom_signal();
684        let scrollable = view.with_scroll_bars();
685        let max_x = scrollable.max_scroll_x_signal().clone();
686
687        let mut tree = WidgetTree::new();
688        let _id = tree.add(scrollable);
689        tree.layout(SizeProposal::exact(400.0, 300.0));
690        assert!(
691            (max_x.get() - 400.0).abs() < 0.5,
692            "zoom=1 max_x = {}",
693            max_x.get()
694        );
695
696        zoom.set(2.0);
697        tree.layout(SizeProposal::exact(400.0, 300.0));
698        // content width = 800 * 2 = 1600; max_x = 1600 - 400 = 1200.
699        assert!(
700            (max_x.get() - 1200.0).abs() < 0.5,
701            "zoom=2 max_x = {}",
702            max_x.get()
703        );
704    }
705
706    /// The load-bearing interaction direction: writing `scroll_pos_x` (what a
707    /// ScrollBar drag does) drives `pan_x`, and the display recompute that
708    /// follows does not re-trigger the effect into a feedback loop.
709    #[test]
710    fn scroll_pos_drives_pan_without_feedback_loop() {
711        let mut scene = Scene::new();
712        scene.set_pan_bounds(Some(Rect::new(0.0, 0.0, 800.0, 600.0)));
713        let view = SceneView::new(scene);
714        let pan_x = view.pan_x_signal();
715        let scrollable = view.with_scroll_bars();
716        let scroll_pos_x = scrollable.scroll_pos_x_signal().clone();
717
718        let mut tree = WidgetTree::new();
719        let _id = tree.add(scrollable);
720        tree.layout(SizeProposal::exact(400.0, 300.0));
721        assert!(pan_x.get().abs() < 0.5, "initial pan_x = {}", pan_x.get());
722
723        // Simulate a drag that scrolls 200 px right (extent.x = 0, zoom = 1, so
724        // pan_x = -extent.x*zoom - scroll_pos = -200).
725        scroll_pos_x.set(200.0);
726        assert!(
727            (pan_x.get() - (-200.0)).abs() < 0.5,
728            "pan_x should follow scroll, got {}",
729            pan_x.get()
730        );
731
732        // Re-layout recomputes scroll_pos_x from the new pan — it must settle at
733        // 200 (consistent), not drift, which would betray a feedback loop.
734        tree.layout(SizeProposal::exact(400.0, 300.0));
735        assert!(
736            (scroll_pos_x.get() - 200.0).abs() < 0.5,
737            "scroll_pos_x should settle at 200, got {}",
738            scroll_pos_x.get()
739        );
740        assert!(
741            (pan_x.get() - (-200.0)).abs() < 0.5,
742            "pan_x should stay at -200, got {}",
743            pan_x.get()
744        );
745    }
746
747    /// A locked pan axis never moves even if its (hidden) bar's position signal
748    /// is written.
749    #[test]
750    fn locked_axis_ignores_scroll_writes() {
751        let mut scene = Scene::new();
752        scene.set_pan_bounds(Some(Rect::new(0.0, 0.0, 800.0, 600.0)));
753        scene.pan_axes(PanAxes::Horizontal); // vertical pan locked
754        let view = SceneView::new(scene);
755        let pan_y = view.pan_y_signal();
756        let scrollable = view.with_scroll_bars();
757        let scroll_pos_y = scrollable.scroll_pos_y_signal().clone();
758
759        let mut tree = WidgetTree::new();
760        let _id = tree.add(scrollable);
761        tree.layout(SizeProposal::exact(400.0, 300.0));
762
763        scroll_pos_y.set(150.0);
764        assert!(
765            pan_y.get().abs() < 0.5,
766            "locked vertical axis must not pan, got {}",
767            pan_y.get()
768        );
769    }
770}