Skip to main content

bevy_react/
scrollbar.rs

1//! Visible, styleable, draggable scrollbars for `overflow: scroll` nodes.
2//!
3//! bevy-react drives scrolling from the wheel (`crate::scroll`) but `bevy_ui`
4//! renders no visible bar. This module adds one, declared per node via the
5//! `scrollbar` style field (`"none" | "default" | {…}`, decoded into
6//! [`ScrollbarSpec`]).
7//!
8//! The mechanics — thumb sizing/positioning, dragging, and track-click paging —
9//! are Bevy's own headless widget (`bevy::ui_widgets::{Scrollbar, ScrollbarThumb}`,
10//! whose `ScrollbarPlugin` ships in `DefaultPlugins`). This module is only the
11//! **shell**: it stamps a [`ScrollbarConfig`] on the scroll container (from the
12//! style), then per active axis spawns a Bevy `Scrollbar` track (styled from
13//! `track`) with a `ScrollbarThumb` child (styled from `thumb`) that **targets**
14//! the container. The track is parented to the container's *own* ECS parent and
15//! positioned each frame over the container's edge, so it is never scrolled or
16//! clipped by the container and stacks just above it (not above the whole app) —
17//! the same overlay trick as [`crate::anchor`], `ChildOf` self-healed against the
18//! reconciler's end-of-batch child rebuild.
19//!
20//! `ScrollbarThumb` has no `Node`, so the styleable surface is deliberately a
21//! small, honest [`ScrollbarPartStyle`] (color/border/radius) rather than a full
22//! `Style` — see its docs.
23
24use std::fmt;
25
26use bevy::picking::hover::Hovered;
27use bevy::platform::collections::HashMap;
28use bevy::prelude::*;
29use bevy::ui::{
30    BackgroundColor, BorderColor, BorderRadius, ComputedNode, Node, OverflowAxis, PositionType,
31    ScrollPosition, UiGlobalTransform, UiRect, Val, ZIndex,
32};
33use bevy::ui_widgets::{ControlOrientation, Scrollbar, ScrollbarDragState, ScrollbarThumb};
34
35use serde::Deserialize;
36use serde::de::{self, Deserializer, MapAccess, Visitor};
37
38use crate::plugin::PointerCapture;
39use crate::protocol::{units::Rect, visual::BorderColorSpec};
40use crate::transition::ScrollTransitionState;
41use crate::ui_map::{parse_color, rect_to_border_radius, rect_to_uirect};
42
43/// Default bar cross-axis thickness (logical px) when the spec doesn't set one.
44pub const DEFAULT_THICKNESS: f32 = 12.0;
45/// Default minimum thumb length (logical px) — keeps the thumb grabbable on very
46/// long content. Maps to `Scrollbar::min_thumb_length`.
47pub const DEFAULT_MIN_THUMB: f32 = 24.0;
48/// The `"default"` scrollbar's track fill.
49const DEFAULT_TRACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.12);
50/// The `"default"` scrollbar's thumb fill.
51const DEFAULT_THUMB_COLOR: Color = Color::srgba(0.55, 0.55, 0.55, 0.9);
52
53/// Whether the bar reserves layout space or floats over content.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum ScrollbarPosition {
56    /// Reserve gutter space (via `Node.scrollbar_width`); content shrinks and the
57    /// bar sits in its own track. The default.
58    #[default]
59    Gutter,
60    /// Reserve nothing; the bar floats over content on the chosen edge.
61    Float,
62}
63
64/// Which edge the vertical bar sits on.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum HorizontalEdge {
67    Left,
68    #[default]
69    Right,
70}
71
72/// Which edge the horizontal bar sits on.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum VerticalEdge {
75    Top,
76    #[default]
77    Bottom,
78}
79
80/// Which interaction state a part is painted in. `Pressed` (the bar is being
81/// dragged) wins over `Hover` (the pointer is over the part); both overlay `Base`.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum PartState {
84    Base,
85    Hover,
86    Pressed,
87}
88
89/// The honest, dedicated style for one scrollbar part (track or thumb). **Not**
90/// the full `Style`: Bevy's `ScrollbarThumb` has no `Node` and owns its own
91/// size/position, so only these four visual fields map through — listing exactly
92/// them keeps the API from silently ignoring `width`/`padding`/`flex`/gradients/etc.
93/// (The track *is* a real `Node`, but v1 keeps both parts symmetric.)
94///
95/// `hover`/`pressed` are overlaid on the base fields while the pointer is over the
96/// part / while the bar is being dragged (a variant's own nested `hover`/`pressed`
97/// are ignored — one level only).
98#[derive(Debug, Clone, Default, PartialEq)]
99pub struct ScrollbarPartStyle {
100    /// Fill color (hex / CSS color) → `BackgroundColor`.
101    pub background_color: Option<String>,
102    /// Border color(s) → `BorderColor`.
103    pub border_color: Option<BorderColorSpec>,
104    /// Corner radii → `BorderRadius` / `ScrollbarThumb.border_radius`.
105    pub border_radius: Option<Rect>,
106    /// Border thickness → the node border / `ScrollbarThumb.border`.
107    pub border: Option<Rect>,
108    /// Overlaid while the pointer is over this part.
109    pub hover: Option<Box<ScrollbarPartStyle>>,
110    /// Overlaid while the bar is being dragged (wins over `hover`).
111    pub pressed: Option<Box<ScrollbarPartStyle>>,
112}
113
114impl ScrollbarPartStyle {
115    /// The sub-style overlaid for `state` (`None` for `Base`).
116    fn variant(&self, state: PartState) -> Option<&ScrollbarPartStyle> {
117        match state {
118            PartState::Base => None,
119            PartState::Hover => self.hover.as_deref(),
120            PartState::Pressed => self.pressed.as_deref(),
121        }
122    }
123}
124
125impl<'de> Deserialize<'de> for ScrollbarPartStyle {
126    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
127        struct PartVisitor;
128        impl<'de> Visitor<'de> for PartVisitor {
129            type Value = ScrollbarPartStyle;
130            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
131                f.write_str("a scrollbar part style object")
132            }
133            fn visit_map<A: MapAccess<'de>>(
134                self,
135                mut map: A,
136            ) -> Result<ScrollbarPartStyle, A::Error> {
137                let mut part = ScrollbarPartStyle::default();
138                while let Some(key) = map.next_key::<String>()? {
139                    match key.as_str() {
140                        "backgroundColor" => part.background_color = map.next_value()?,
141                        "borderColor" => part.border_color = map.next_value()?,
142                        "borderRadius" => part.border_radius = map.next_value()?,
143                        "border" => part.border = map.next_value()?,
144                        "hover" => {
145                            part.hover = map
146                                .next_value::<Option<ScrollbarPartStyle>>()?
147                                .map(Box::new)
148                        }
149                        "pressed" => {
150                            part.pressed = map
151                                .next_value::<Option<ScrollbarPartStyle>>()?
152                                .map(Box::new)
153                        }
154                        // Unknown key: consume the value and skip (a typo must not
155                        // abort the whole commit batch — like the rest of the protocol).
156                        _ => {
157                            let _ = map.next_value::<de::IgnoredAny>()?;
158                            crate::protocol::decode_warn(
159                                "scrollbar",
160                                &key,
161                                &format!("unknown scrollbar part field {key:?}; ignoring"),
162                            );
163                        }
164                    }
165                }
166                Ok(part)
167            }
168        }
169        d.deserialize_map(PartVisitor)
170    }
171}
172
173/// A fully-configured scrollbar (the payload of [`ScrollbarSpec::Styled`]). Boxed
174/// in the enum so `ScrollbarSpec` — and thus [`crate::protocol::style::Style`], which
175/// embeds it four times over — stays small.
176#[derive(Debug, Clone, PartialEq, Default)]
177pub struct ScrollbarStyled {
178    pub track: Option<ScrollbarPartStyle>,
179    pub thumb: Option<ScrollbarPartStyle>,
180    pub thickness: Option<f32>,
181    pub min_thumb_length: Option<f32>,
182    pub position: ScrollbarPosition,
183    pub vertical_side: HorizontalEdge,
184    pub horizontal_side: VerticalEdge,
185}
186
187/// The decoded `scrollbar` style field: `"none"`, `"default"`, or a styled object.
188#[derive(Debug, Clone, PartialEq, Default)]
189pub enum ScrollbarSpec {
190    /// No visible bar.
191    #[default]
192    None,
193    /// The built-in neutral bar (gutter, right/bottom, ~12px).
194    Default,
195    /// A fully-configured bar.
196    Styled(Box<ScrollbarStyled>),
197}
198
199impl ScrollbarSpec {
200    /// A visible bar (anything but `None`).
201    pub fn is_visible(&self) -> bool {
202        !matches!(self, ScrollbarSpec::None)
203    }
204
205    /// The `Styled` payload, if any.
206    fn styled(&self) -> Option<&ScrollbarStyled> {
207        match self {
208            ScrollbarSpec::Styled(s) => Some(s),
209            _ => None,
210        }
211    }
212
213    /// Bar cross-axis thickness in logical px.
214    pub fn thickness(&self) -> f32 {
215        self.styled()
216            .and_then(|s| s.thickness)
217            .unwrap_or(DEFAULT_THICKNESS)
218    }
219
220    /// Minimum thumb length in logical px.
221    pub fn min_thumb_length(&self) -> f32 {
222        self.styled()
223            .and_then(|s| s.min_thumb_length)
224            .unwrap_or(DEFAULT_MIN_THUMB)
225    }
226
227    /// Gutter (reserve space) vs float (overlay).
228    pub fn position(&self) -> ScrollbarPosition {
229        self.styled().map(|s| s.position).unwrap_or_default()
230    }
231
232    /// Which edge the vertical bar sits on.
233    pub fn vertical_side(&self) -> HorizontalEdge {
234        self.styled().map(|s| s.vertical_side).unwrap_or_default()
235    }
236
237    /// Which edge the horizontal bar sits on.
238    pub fn horizontal_side(&self) -> VerticalEdge {
239        self.styled().map(|s| s.horizontal_side).unwrap_or_default()
240    }
241
242    fn track_style(&self) -> Option<&ScrollbarPartStyle> {
243        self.styled().and_then(|s| s.track.as_ref())
244    }
245
246    fn thumb_style(&self) -> Option<&ScrollbarPartStyle> {
247        self.styled().and_then(|s| s.thumb.as_ref())
248    }
249}
250
251impl<'de> Deserialize<'de> for ScrollbarSpec {
252    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
253        struct SpecVisitor;
254        impl<'de> Visitor<'de> for SpecVisitor {
255            type Value = ScrollbarSpec;
256            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
257                f.write_str("\"none\", \"default\", or a scrollbar style object")
258            }
259            fn visit_str<E: de::Error>(self, s: &str) -> Result<ScrollbarSpec, E> {
260                Ok(match s {
261                    "none" => ScrollbarSpec::None,
262                    "default" => ScrollbarSpec::Default,
263                    other => {
264                        crate::protocol::decode_warn(
265                            "scrollbar",
266                            other,
267                            &format!("unknown scrollbar keyword {other:?}; using \"none\""),
268                        );
269                        ScrollbarSpec::None
270                    }
271                })
272            }
273            // A JSON `null` (an unset field) decodes to `None`.
274            fn visit_unit<E: de::Error>(self) -> Result<ScrollbarSpec, E> {
275                Ok(ScrollbarSpec::None)
276            }
277            fn visit_none<E: de::Error>(self) -> Result<ScrollbarSpec, E> {
278                Ok(ScrollbarSpec::None)
279            }
280            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<ScrollbarSpec, A::Error> {
281                let mut track = None;
282                let mut thumb = None;
283                let mut thickness = None;
284                let mut min_thumb_length = None;
285                let mut position = ScrollbarPosition::default();
286                let mut vertical_side = HorizontalEdge::default();
287                let mut horizontal_side = VerticalEdge::default();
288                while let Some(key) = map.next_key::<String>()? {
289                    match key.as_str() {
290                        "track" => track = map.next_value()?,
291                        "thumb" => thumb = map.next_value()?,
292                        "thickness" => thickness = map.next_value()?,
293                        "minThumbLength" => min_thumb_length = map.next_value()?,
294                        "position" => {
295                            position = match map.next_value::<String>()?.as_str() {
296                                "float" => ScrollbarPosition::Float,
297                                "gutter" => ScrollbarPosition::Gutter,
298                                other => {
299                                    crate::protocol::decode_warn(
300                                        "scrollbar",
301                                        other,
302                                        &format!(
303                                            "unknown scrollbar position {other:?}; using \"gutter\""
304                                        ),
305                                    );
306                                    ScrollbarPosition::Gutter
307                                }
308                            }
309                        }
310                        "verticalSide" => {
311                            vertical_side = match map.next_value::<String>()?.as_str() {
312                                "left" => HorizontalEdge::Left,
313                                "right" => HorizontalEdge::Right,
314                                other => {
315                                    crate::protocol::decode_warn(
316                                        "scrollbar",
317                                        other,
318                                        &format!(
319                                            "unknown scrollbar verticalSide {other:?}; using \"right\""
320                                        ),
321                                    );
322                                    HorizontalEdge::Right
323                                }
324                            }
325                        }
326                        "horizontalSide" => {
327                            horizontal_side = match map.next_value::<String>()?.as_str() {
328                                "top" => VerticalEdge::Top,
329                                "bottom" => VerticalEdge::Bottom,
330                                other => {
331                                    crate::protocol::decode_warn(
332                                        "scrollbar",
333                                        other,
334                                        &format!(
335                                            "unknown scrollbar horizontalSide {other:?}; using \"bottom\""
336                                        ),
337                                    );
338                                    VerticalEdge::Bottom
339                                }
340                            }
341                        }
342                        _ => {
343                            let _ = map.next_value::<de::IgnoredAny>()?;
344                            crate::protocol::decode_warn(
345                                "scrollbar",
346                                &key,
347                                &format!("unknown scrollbar field {key:?}; ignoring"),
348                            );
349                        }
350                    }
351                }
352                Ok(ScrollbarSpec::Styled(Box::new(ScrollbarStyled {
353                    track,
354                    thumb,
355                    thickness,
356                    min_thumb_length,
357                    position,
358                    vertical_side,
359                    horizontal_side,
360                })))
361            }
362        }
363        d.deserialize_any(SpecVisitor)
364    }
365}
366
367/// Stamped on a scroll container (from its `scrollbar` style field) when the spec
368/// is visible; removed when it's `"none"`/absent. Read by [`sync_scrollbars`],
369/// which spawns/despawns the Bevy widget entities that render the bar.
370#[derive(Component, Debug, Clone, PartialEq)]
371pub struct ScrollbarConfig(pub ScrollbarSpec);
372
373/// One axis's spawned widget entities (the track and its thumb child).
374#[derive(Clone, Copy)]
375struct AxisEntities {
376    track: Entity,
377    #[allow(dead_code)] // despawned via the recursive track despawn; kept for clarity/tests
378    thumb: Entity,
379}
380
381/// Per-container spawned scrollbar state, keyed by the container entity.
382struct ContainerTracks {
383    vertical: Option<AxisEntities>,
384    horizontal: Option<AxisEntities>,
385    /// The spec these entities were built from; a change tears them down and rebuilds.
386    spec: ScrollbarSpec,
387}
388
389/// Maps each `ScrollbarConfig` container to its spawned widget entities. Mirrors
390/// the `surface_parent` side-map pattern — the widget entities are Rust-managed
391/// (not reconciler nodes), so they live outside the shadow tree.
392#[derive(Resource, Default)]
393pub struct ScrollbarTracks(HashMap<Entity, ContainerTracks>);
394
395// --- style resolution (spec part + state -> bevy components) ---
396
397/// The bevy visual components a part resolves to for a given [`PartState`].
398struct EffectiveVisual {
399    background: BackgroundColor,
400    border_color: BorderColor,
401    border: UiRect,
402    border_radius: BorderRadius,
403}
404
405/// Resolve a part's four visual fields for `state`: each field takes the state
406/// variant's value if set, else the base value, else the supplied default.
407fn resolve_visual(
408    part: Option<&ScrollbarPartStyle>,
409    state: PartState,
410    default_bg: Color,
411    default_radius: BorderRadius,
412) -> EffectiveVisual {
413    let ov = part.and_then(|p| p.variant(state));
414    let background = ov
415        .and_then(|o| o.background_color.as_deref())
416        .or_else(|| part.and_then(|p| p.background_color.as_deref()))
417        .map(parse_color)
418        .unwrap_or(default_bg);
419    let side = |c: &Option<String>| c.as_deref().map(parse_color).unwrap_or(Color::NONE);
420    let border_color = match ov
421        .and_then(|o| o.border_color.as_ref())
422        .or_else(|| part.and_then(|p| p.border_color.as_ref()))
423    {
424        Some(spec) => BorderColor {
425            top: side(&spec.top),
426            right: side(&spec.right),
427            bottom: side(&spec.bottom),
428            left: side(&spec.left),
429        },
430        None => BorderColor::all(Color::NONE),
431    };
432    let border = ov
433        .and_then(|o| o.border)
434        .or_else(|| part.and_then(|p| p.border))
435        .map(rect_to_uirect)
436        .unwrap_or(UiRect::ZERO);
437    let border_radius = ov
438        .and_then(|o| o.border_radius)
439        .or_else(|| part.and_then(|p| p.border_radius))
440        .map(rect_to_border_radius)
441        .unwrap_or(default_radius);
442    EffectiveVisual {
443        background: BackgroundColor(background),
444        border_color,
445        border,
446        border_radius,
447    }
448}
449
450/// The thumb's default corner radius (a pill at the default thickness).
451fn thumb_default_radius(thickness: f32) -> BorderRadius {
452    BorderRadius::all(Val::Px(thickness * 0.5))
453}
454
455/// Spawn one axis's `Scrollbar` track (child of `parent`) + `ScrollbarThumb` child.
456fn spawn_axis(
457    commands: &mut Commands,
458    container: Entity,
459    parent: Entity,
460    spec: &ScrollbarSpec,
461    orientation: ControlOrientation,
462) -> AxisEntities {
463    let thickness = spec.thickness();
464    // Base-state visuals; `style_scrollbar_states` re-derives per hover/press each frame.
465    let tv = resolve_visual(
466        spec.track_style(),
467        PartState::Base,
468        DEFAULT_TRACK_COLOR,
469        BorderRadius::ZERO,
470    );
471    let hv = resolve_visual(
472        spec.thumb_style(),
473        PartState::Base,
474        DEFAULT_THUMB_COLOR,
475        thumb_default_radius(thickness),
476    );
477    // Track: an absolutely-positioned node (placed each frame by `position_scrollbars`),
478    // above its siblings within the parent's stacking context, styled from `track`.
479    let track = commands
480        .spawn((
481            Scrollbar::new(container, orientation, spec.min_thumb_length()),
482            Node {
483                position_type: PositionType::Absolute,
484                border: tv.border,
485                border_radius: tv.border_radius,
486                ..default()
487            },
488            tv.background,
489            tv.border_color,
490            // Above the container (a sibling) but inside the parent's stacking context,
491            // so a globally-higher modal still covers it.
492            ZIndex(i32::MAX),
493            // Start hidden; `position_scrollbars` reveals it once it has a scroll range.
494            Visibility::Hidden,
495            // Track-click paging needs the track to be pickable; `Hovered` tracks its
496            // pointer-over state for `style_scrollbar_states` (auto-updated by picking).
497            Pickable {
498                should_block_lower: true,
499                is_hoverable: true,
500            },
501            Hovered::default(),
502            ChildOf(parent),
503        ))
504        .id();
505    // Thumb: no `Node` — Bevy sizes/positions it. `ScrollbarThumb`'s `#[require]`
506    // already adds BackgroundColor/BorderColor/etc.; we override the visual ones.
507    // `Hovered` follows Bevy's own scrollbars example for hover styling.
508    let thumb = commands
509        .spawn((
510            ScrollbarThumb {
511                border_radius: hv.border_radius,
512                border: hv.border,
513            },
514            hv.background,
515            hv.border_color,
516            Pickable {
517                should_block_lower: true,
518                is_hoverable: true,
519            },
520            Hovered::default(),
521            ChildOf(track),
522        ))
523        .id();
524    AxisEntities { track, thumb }
525}
526
527/// Reconcile the spawned widget entities against every [`ScrollbarConfig`]
528/// container: spawn a track+thumb per active (`overflow: scroll`) axis, tear down
529/// axes that turn off, rebuild on a spec change, and drop everything for a
530/// container that lost its config or was despawned.
531pub fn sync_scrollbars(
532    mut commands: Commands,
533    mut tracks: ResMut<ScrollbarTracks>,
534    q_containers: Query<(Entity, &ScrollbarConfig, &Node, &ChildOf), Without<Scrollbar>>,
535) {
536    use bevy::platform::collections::HashSet;
537    let mut seen: HashSet<Entity> = HashSet::new();
538
539    for (container, config, node, child_of) in &q_containers {
540        seen.insert(container);
541        let parent = child_of.parent();
542        let entry = tracks
543            .0
544            .entry(container)
545            .or_insert_with(|| ContainerTracks {
546                vertical: None,
547                horizontal: None,
548                spec: config.0.clone(),
549            });
550
551        // A spec change (e.g. a hover/press variant) tears the bars down; they're
552        // rebuilt below from the new spec.
553        if entry.spec != config.0 {
554            for axis in [entry.vertical.take(), entry.horizontal.take()]
555                .into_iter()
556                .flatten()
557            {
558                commands.entity(axis.track).despawn();
559            }
560            entry.spec = config.0.clone();
561        }
562
563        let want_v = node.overflow.y == OverflowAxis::Scroll;
564        let want_h = node.overflow.x == OverflowAxis::Scroll;
565
566        if want_v && entry.vertical.is_none() {
567            entry.vertical = Some(spawn_axis(
568                &mut commands,
569                container,
570                parent,
571                &config.0,
572                ControlOrientation::Vertical,
573            ));
574        } else if !want_v && let Some(axis) = entry.vertical.take() {
575            commands.entity(axis.track).despawn();
576        }
577
578        if want_h && entry.horizontal.is_none() {
579            entry.horizontal = Some(spawn_axis(
580                &mut commands,
581                container,
582                parent,
583                &config.0,
584                ControlOrientation::Horizontal,
585            ));
586        } else if !want_h && let Some(axis) = entry.horizontal.take() {
587            commands.entity(axis.track).despawn();
588        }
589    }
590
591    // Drop entries whose container lost its `ScrollbarConfig` or was despawned.
592    tracks.0.retain(|container, entry| {
593        if seen.contains(container) {
594            return true;
595        }
596        for axis in [entry.vertical, entry.horizontal].into_iter().flatten() {
597            commands.entity(axis.track).try_despawn();
598        }
599        false
600    });
601}
602
603/// The logical scroll range per axis (0 means "no scroll"), mirroring
604/// `crate::scroll::apply_scroll`'s formula.
605fn scroll_max(computed: &ComputedNode) -> Vec2 {
606    (computed.content_size - computed.size + computed.scrollbar_size).max(Vec2::ZERO)
607        * computed.inverse_scale_factor
608}
609
610/// The track's `(left/top, width/height)` in the parent's local (logical) space,
611/// for a container whose top-left sits at `rel` (logical, relative to the parent's
612/// top-left) with logical `size`. `thickness` is the bar's cross-axis size.
613fn place_track(
614    rel: Vec2,
615    size: Vec2,
616    thickness: f32,
617    orientation: ControlOrientation,
618    v_side: HorizontalEdge,
619    h_side: VerticalEdge,
620) -> (Vec2, Vec2) {
621    match orientation {
622        ControlOrientation::Vertical => {
623            let left = match v_side {
624                HorizontalEdge::Right => rel.x + size.x - thickness,
625                HorizontalEdge::Left => rel.x,
626            };
627            (Vec2::new(left, rel.y), Vec2::new(thickness, size.y))
628        }
629        ControlOrientation::Horizontal => {
630            let top = match h_side {
631                VerticalEdge::Bottom => rel.y + size.y - thickness,
632                VerticalEdge::Top => rel.y,
633            };
634            (Vec2::new(rel.x, top), Vec2::new(size.x, thickness))
635        }
636    }
637}
638
639/// Position each spawned track over its container's chosen edge (in the parent's
640/// coordinate space) and hide it when its axis has no scroll range. Bevy's
641/// `update_scrollbar_thumb` positions the thumb *within* the track afterward
642/// (PostUpdate). Also self-heals the track's `ChildOf` against the reconciler's
643/// end-of-batch child rebuild (the anchored-node trick).
644#[allow(clippy::type_complexity)]
645pub fn position_scrollbars(
646    mut commands: Commands,
647    tracks: Res<ScrollbarTracks>,
648    q_containers: Query<
649        (
650            &ScrollbarConfig,
651            &ComputedNode,
652            &UiGlobalTransform,
653            &ChildOf,
654        ),
655        Without<Scrollbar>,
656    >,
657    q_parents: Query<(&ComputedNode, &UiGlobalTransform)>,
658    mut q_tracks: Query<(&mut Node, &mut Visibility, Option<&ChildOf>), With<Scrollbar>>,
659) {
660    for (&container, entry) in tracks.0.iter() {
661        let Ok((config, computed, transform, child_of)) = q_containers.get(container) else {
662            continue;
663        };
664        let parent = child_of.parent();
665        let Ok((parent_computed, parent_transform)) = q_parents.get(parent) else {
666            continue;
667        };
668
669        let inv = computed.inverse_scale_factor;
670        // Container top-left relative to the parent's top-left, in logical px.
671        // (Assumes the parent has no border; scroll-container parents typically don't.)
672        let container_tl = transform.translation - computed.size * 0.5;
673        let parent_tl = parent_transform.translation - parent_computed.size * 0.5;
674        let rel = (container_tl - parent_tl) * inv;
675        let size = computed.size * inv;
676        let thickness = config.0.thickness();
677        let max = scroll_max(computed);
678
679        let mut apply = |axis: AxisEntities, orientation: ControlOrientation, has_range: bool| {
680            let Ok((mut node, mut visibility, track_child_of)) = q_tracks.get_mut(axis.track)
681            else {
682                return;
683            };
684            // Self-heal: the parent's end-of-batch `replace_children` can transiently
685            // drop this non-shadow child; re-assert it (only when it actually diverged).
686            if track_child_of.map(|c| c.parent()) != Some(parent) {
687                commands.entity(axis.track).insert(ChildOf(parent));
688            }
689            let next_vis = if has_range {
690                Visibility::Inherited
691            } else {
692                Visibility::Hidden
693            };
694            if *visibility != next_vis {
695                *visibility = next_vis;
696            }
697            if !has_range {
698                return;
699            }
700            let (pos, dims) = place_track(
701                rel,
702                size,
703                thickness,
704                orientation,
705                config.0.vertical_side(),
706                config.0.horizontal_side(),
707            );
708            node.left = Val::Px(pos.x);
709            node.top = Val::Px(pos.y);
710            node.width = Val::Px(dims.x);
711            node.height = Val::Px(dims.y);
712        };
713
714        if let Some(axis) = entry.vertical {
715            apply(axis, ControlOrientation::Vertical, max.y > 0.0);
716        }
717        if let Some(axis) = entry.horizontal {
718            apply(axis, ControlOrientation::Horizontal, max.x > 0.0);
719        }
720    }
721}
722
723/// Bridge Bevy's scrollbar interaction into bevy-react's frame state. Hovering
724/// any scrollbar part claims the hover channel — the widget entities carry
725/// picking's `Hovered`, not `Interaction`, so `collect_pointer_events` can't see
726/// them, and a world grab (the camera) latches on the *press* frame, before
727/// Bevy's drag threshold flips `ScrollbarDragState.dragging` on. While a thumb
728/// is then being dragged, claim the pointer for the whole gesture (so world
729/// input ignores it even once the cursor leaves the bar) and — if the target
730/// container eases its scroll — keep the eased state snapped to the live offset
731/// so `drive_scroll_transition` neither lags the drag nor lets a same-frame
732/// wheel target pull the offset out from under it.
733#[allow(clippy::type_complexity)]
734pub fn bridge_scrollbar_capture(
735    q_drag: Query<(&ScrollbarDragState, &ChildOf), With<ScrollbarThumb>>,
736    q_hover: Query<&Hovered, Or<(With<Scrollbar>, With<ScrollbarThumb>)>>,
737    q_scrollbar: Query<&Scrollbar>,
738    mut q_scroll: Query<(&ScrollPosition, &mut ScrollTransitionState)>,
739    mut capture: ResMut<PointerCapture>,
740) {
741    if q_hover.iter().any(|h| h.0) {
742        capture.over_ui = true;
743    }
744    for (drag, child_of) in &q_drag {
745        if !drag.dragging {
746            continue;
747        }
748        capture.dragging = true;
749        capture.over_ui = true;
750        if let Ok(scrollbar) = q_scrollbar.get(child_of.parent())
751            && let Ok((pos, mut state)) = q_scroll.get_mut(scrollbar.target)
752        {
753            state.snap_to(pos.0);
754        }
755    }
756}
757
758/// Which state a part is in: `pressed` (its bar is being dragged) > `hover` (the
759/// pointer is over it) > base.
760fn part_state(dragging: bool, hovered: bool) -> PartState {
761    if dragging {
762        PartState::Pressed
763    } else if hovered {
764        PartState::Hover
765    } else {
766        PartState::Base
767    }
768}
769
770/// Paint each scrollbar part in its current interaction state, following Bevy's own
771/// scrollbars example: `hover` uses the `Hovered` component (auto-updated by
772/// picking), `pressed` uses the bar's `ScrollbarDragState.dragging`. Idempotent —
773/// only writes a component when the resolved value actually changed.
774#[allow(clippy::type_complexity)]
775pub fn style_scrollbar_states(
776    tracks: Res<ScrollbarTracks>,
777    mut dirt: ResMut<crate::layer::LayerContentDirt>,
778    q_config: Query<&ScrollbarConfig>,
779    q_hovered: Query<&Hovered>,
780    q_drag: Query<&ScrollbarDragState, With<ScrollbarThumb>>,
781    mut q_track: Query<
782        (&mut BackgroundColor, &mut BorderColor, &mut Node),
783        (With<Scrollbar>, Without<ScrollbarThumb>),
784    >,
785    mut q_thumb: Query<
786        (&mut BackgroundColor, &mut BorderColor, &mut ScrollbarThumb),
787        (With<ScrollbarThumb>, Without<Scrollbar>),
788    >,
789) {
790    for (&container, entry) in tracks.0.iter() {
791        let Ok(config) = q_config.get(container) else {
792            continue;
793        };
794        let spec = &config.0;
795        let thickness = spec.thickness();
796
797        for axis in [entry.vertical, entry.horizontal].into_iter().flatten() {
798            // The whole bar is "pressed" while its thumb is being dragged.
799            let dragging = q_drag.get(axis.thumb).map(|d| d.dragging).unwrap_or(false);
800
801            if let Ok((mut bg, mut bc, mut node)) = q_track.get_mut(axis.track) {
802                let hovered = q_hovered.get(axis.track).map(|h| h.0).unwrap_or(false);
803                let v = resolve_visual(
804                    spec.track_style(),
805                    part_state(dragging, hovered),
806                    DEFAULT_TRACK_COLOR,
807                    BorderRadius::ZERO,
808                );
809                let mut wrote = bg.set_if_neq(v.background);
810                wrote |= bc.set_if_neq(v.border_color);
811                // `node.border`/`border_radius` guard is manual so we don't trip Node
812                // change detection (a relayout) on a paint-only frame.
813                if node.border != v.border || node.border_radius != v.border_radius {
814                    node.border = v.border;
815                    node.border_radius = v.border_radius;
816                    wrote = true;
817                }
818                if wrote {
819                    // Layer-cache tap: bar repaint inside a promoted subtree.
820                    dirt.nodes.push(axis.track);
821                }
822            }
823
824            if let Ok((mut bg, mut bc, mut thumb)) = q_thumb.get_mut(axis.thumb) {
825                let hovered = q_hovered.get(axis.thumb).map(|h| h.0).unwrap_or(false);
826                let v = resolve_visual(
827                    spec.thumb_style(),
828                    part_state(dragging, hovered),
829                    DEFAULT_THUMB_COLOR,
830                    thumb_default_radius(thickness),
831                );
832                let mut wrote = bg.set_if_neq(v.background);
833                wrote |= bc.set_if_neq(v.border_color);
834                if thumb.border != v.border || thumb.border_radius != v.border_radius {
835                    thumb.border = v.border;
836                    thumb.border_radius = v.border_radius;
837                    wrote = true;
838                }
839                if wrote {
840                    dirt.nodes.push(axis.thumb);
841                }
842            }
843        }
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use crate::protocol::style::Style;
851
852    #[test]
853    fn decodes_none_and_default_keywords() {
854        let style: Style = serde_json::from_value(serde_json::json!({ "scrollbar": "none" }))
855            .expect("decode none");
856        assert_eq!(style.scrollbar, Some(ScrollbarSpec::None));
857
858        let style: Style = serde_json::from_value(serde_json::json!({ "scrollbar": "default" }))
859            .expect("decode default");
860        assert_eq!(style.scrollbar, Some(ScrollbarSpec::Default));
861    }
862
863    #[test]
864    fn decodes_styled_object_round_trip() {
865        let style: Style = serde_json::from_value(serde_json::json!({
866            "scrollbar": {
867                "track": { "backgroundColor": "#111111", "borderRadius": 4 },
868                "thumb": { "backgroundColor": "#888888" },
869                "thickness": 8,
870                "minThumbLength": 30,
871                "position": "float",
872                "verticalSide": "left",
873                "horizontalSide": "top",
874            }
875        }))
876        .expect("decode styled");
877        let spec = style.scrollbar.expect("present");
878        assert_eq!(spec.thickness(), 8.0);
879        assert_eq!(spec.min_thumb_length(), 30.0);
880        assert_eq!(spec.position(), ScrollbarPosition::Float);
881        assert_eq!(spec.vertical_side(), HorizontalEdge::Left);
882        assert_eq!(spec.horizontal_side(), VerticalEdge::Top);
883        let track = spec.track_style().expect("track");
884        assert_eq!(track.background_color.as_deref(), Some("#111111"));
885        assert!(spec.thumb_style().is_some());
886    }
887
888    #[test]
889    fn unknown_keyword_falls_back_to_none() {
890        let style: Style = serde_json::from_value(serde_json::json!({ "scrollbar": "wat" }))
891            .expect("must not error on a bad keyword");
892        assert_eq!(style.scrollbar, Some(ScrollbarSpec::None));
893    }
894
895    #[test]
896    fn decodes_hover_and_pressed_variants() {
897        let style: Style = serde_json::from_value(serde_json::json!({
898            "scrollbar": {
899                "thumb": {
900                    "backgroundColor": "#888888",
901                    "hover": { "backgroundColor": "#aaaaaa" },
902                    "pressed": { "backgroundColor": "#c4b5fd" },
903                }
904            }
905        }))
906        .expect("decode variants");
907        let spec = style.scrollbar.expect("present");
908        let thumb = spec.thumb_style().expect("thumb");
909        assert_eq!(thumb.background_color.as_deref(), Some("#888888"));
910        assert_eq!(
911            thumb
912                .variant(PartState::Hover)
913                .unwrap()
914                .background_color
915                .as_deref(),
916            Some("#aaaaaa")
917        );
918        assert_eq!(
919            thumb
920                .variant(PartState::Pressed)
921                .unwrap()
922                .background_color
923                .as_deref(),
924            Some("#c4b5fd")
925        );
926    }
927
928    #[test]
929    fn resolve_visual_precedence_pressed_over_hover_over_base() {
930        // Base blue; hover green; pressed omits color (falls back to base), overrides radius.
931        let part = ScrollbarPartStyle {
932            background_color: Some("#0000ff".into()),
933            border_radius: Some(Rect::default()),
934            hover: Some(Box::new(ScrollbarPartStyle {
935                background_color: Some("#00ff00".into()),
936                ..default()
937            })),
938            pressed: Some(Box::new(ScrollbarPartStyle {
939                border_radius: Some(Rect::default()),
940                ..default()
941            })),
942            ..default()
943        };
944        let base = resolve_visual(
945            Some(&part),
946            PartState::Base,
947            Color::WHITE,
948            BorderRadius::ZERO,
949        );
950        assert_eq!(base.background.0, parse_color("#0000ff"));
951
952        let hover = resolve_visual(
953            Some(&part),
954            PartState::Hover,
955            Color::WHITE,
956            BorderRadius::ZERO,
957        );
958        assert_eq!(
959            hover.background.0,
960            parse_color("#00ff00"),
961            "hover overrides base"
962        );
963
964        // Pressed sets no color → falls back to the base color (not hover's).
965        let pressed = resolve_visual(
966            Some(&part),
967            PartState::Pressed,
968            Color::WHITE,
969            BorderRadius::ZERO,
970        );
971        assert_eq!(
972            pressed.background.0,
973            parse_color("#0000ff"),
974            "pressed with no color falls back to base, not hover"
975        );
976
977        // A part with no styling uses the supplied default.
978        let none = resolve_visual(None, PartState::Hover, Color::WHITE, BorderRadius::ZERO);
979        assert_eq!(none.background.0, Color::WHITE);
980    }
981
982    #[test]
983    fn defaults_when_object_omits_fields() {
984        let style: Style =
985            serde_json::from_value(serde_json::json!({ "scrollbar": {} })).expect("decode empty");
986        let spec = style.scrollbar.expect("present");
987        assert_eq!(spec.thickness(), DEFAULT_THICKNESS);
988        assert_eq!(spec.min_thumb_length(), DEFAULT_MIN_THUMB);
989        assert_eq!(spec.position(), ScrollbarPosition::Gutter);
990        assert_eq!(spec.vertical_side(), HorizontalEdge::Right);
991        assert_eq!(spec.horizontal_side(), VerticalEdge::Bottom);
992    }
993
994    #[test]
995    fn scroll_max_is_zero_when_content_fits() {
996        let fits = ComputedNode {
997            size: Vec2::new(100.0, 100.0),
998            content_size: Vec2::new(100.0, 100.0),
999            inverse_scale_factor: 1.0,
1000            ..default()
1001        };
1002        assert_eq!(scroll_max(&fits), Vec2::ZERO);
1003
1004        let overflowing = ComputedNode {
1005            size: Vec2::new(100.0, 100.0),
1006            content_size: Vec2::new(100.0, 300.0),
1007            inverse_scale_factor: 1.0,
1008            ..default()
1009        };
1010        assert_eq!(scroll_max(&overflowing), Vec2::new(0.0, 200.0));
1011    }
1012
1013    #[test]
1014    fn places_vertical_track_on_the_right_edge() {
1015        // A 200x100 container at rel (10, 20); a 12px vertical bar on the right.
1016        let (pos, dims) = place_track(
1017            Vec2::new(10.0, 20.0),
1018            Vec2::new(200.0, 100.0),
1019            12.0,
1020            ControlOrientation::Vertical,
1021            HorizontalEdge::Right,
1022            VerticalEdge::Bottom,
1023        );
1024        assert_eq!(pos, Vec2::new(10.0 + 200.0 - 12.0, 20.0));
1025        assert_eq!(dims, Vec2::new(12.0, 100.0));
1026    }
1027
1028    #[test]
1029    fn places_horizontal_track_on_the_top_edge() {
1030        let (pos, dims) = place_track(
1031            Vec2::new(10.0, 20.0),
1032            Vec2::new(200.0, 100.0),
1033            12.0,
1034            ControlOrientation::Horizontal,
1035            HorizontalEdge::Right,
1036            VerticalEdge::Top,
1037        );
1038        assert_eq!(pos, Vec2::new(10.0, 20.0));
1039        assert_eq!(dims, Vec2::new(200.0, 12.0));
1040    }
1041
1042    /// A thumb drag on an eased container bypasses the ease entirely: the widget's
1043    /// direct `ScrollPosition` write survives `bridge_scrollbar_capture` +
1044    /// `drive_scroll_transition` exactly — zero lag, no ease-back to the stale target.
1045    #[test]
1046    fn thumb_drag_bypasses_scroll_easing() {
1047        use crate::animations::Easing;
1048        use crate::protocol::units::Time as WireTime;
1049        use crate::transition::{
1050            ChannelTransition, ScrollTransitionInput, drive_scroll_transition,
1051        };
1052        use bevy::ecs::system::RunSystemOnce;
1053        use bevy::ui::ScrollPosition;
1054        use std::time::Duration;
1055
1056        let spec = ChannelTransition {
1057            duration: Some(WireTime::from_secs(1.0)),
1058            easing: Easing::Linear,
1059            delay: WireTime::from_secs(0.0),
1060            stiffness: None,
1061            damping: None,
1062            mass: 1.0,
1063        };
1064
1065        let mut world = World::new();
1066        world.insert_resource(Time::<()>::default());
1067        world.insert_resource(PointerCapture::default());
1068        let container = world
1069            .spawn((
1070                ScrollPosition::default(),
1071                ScrollTransitionInput(spec),
1072                ScrollTransitionState::default(),
1073            ))
1074            .id();
1075        let track = world
1076            .spawn(Scrollbar::new(
1077                container,
1078                ControlOrientation::Vertical,
1079                20.0,
1080            ))
1081            .id();
1082        let thumb = world
1083            .spawn((ScrollbarThumb::default(), ChildOf(track)))
1084            .id();
1085
1086        // Same frame order as `plugin.rs`: the bridge (PointerCaptureSet) runs
1087        // before the ease.
1088        fn tick(world: &mut World, dt: f32) {
1089            world
1090                .resource_mut::<Time>()
1091                .advance_by(Duration::from_secs_f32(dt));
1092            world.run_system_once(bridge_scrollbar_capture).unwrap();
1093            world.run_system_once(drive_scroll_transition).unwrap();
1094        }
1095
1096        // Seed the resting state, then leave a stale ease mid-flight toward y=100.
1097        tick(&mut world, 0.0);
1098        world
1099            .entity_mut(container)
1100            .get_mut::<ScrollTransitionState>()
1101            .unwrap()
1102            .target = Vec2::new(0.0, 100.0);
1103        tick(&mut world, 0.5);
1104        let mid = world.entity(container).get::<ScrollPosition>().unwrap().0;
1105        assert!(
1106            mid.y > 0.0 && mid.y < 100.0,
1107            "mid-ease expected, got {mid:?}"
1108        );
1109
1110        // Drag starts: Bevy's widget writes the offset directly each frame.
1111        world
1112            .entity_mut(thumb)
1113            .get_mut::<ScrollbarDragState>()
1114            .unwrap()
1115            .dragging = true;
1116        world
1117            .entity_mut(container)
1118            .get_mut::<ScrollPosition>()
1119            .unwrap()
1120            .0 = Vec2::new(0.0, 55.0);
1121        tick(&mut world, 0.5);
1122        assert_eq!(
1123            world.entity(container).get::<ScrollPosition>().unwrap().0,
1124            Vec2::new(0.0, 55.0),
1125            "the drag write must survive the ease untouched"
1126        );
1127        assert!(world.resource::<PointerCapture>().dragging);
1128
1129        // Still parked on later frames — the stale y=100 target is gone.
1130        world
1131            .entity_mut(thumb)
1132            .get_mut::<ScrollbarDragState>()
1133            .unwrap()
1134            .dragging = false;
1135        tick(&mut world, 0.5);
1136        assert_eq!(
1137            world.entity(container).get::<ScrollPosition>().unwrap().0,
1138            Vec2::new(0.0, 55.0)
1139        );
1140    }
1141
1142    /// Hovering any scrollbar part claims the hover channel: the widget entities
1143    /// carry picking's `Hovered`, not `Interaction`, so without this claim a press
1144    /// on the bar reads as free ground and a world grab (the orbit camera) latches
1145    /// on the press frame — before the drag threshold flips `dragging` on.
1146    #[test]
1147    fn hovering_a_scrollbar_part_claims_the_pointer() {
1148        use bevy::ecs::system::RunSystemOnce;
1149        use bevy::ui::ScrollPosition;
1150
1151        let mut world = World::new();
1152        world.insert_resource(PointerCapture::default());
1153        let container = world.spawn(ScrollPosition::default()).id();
1154        let track = world
1155            .spawn((
1156                Scrollbar::new(container, ControlOrientation::Vertical, 20.0),
1157                Hovered(true),
1158            ))
1159            .id();
1160        world.spawn((ScrollbarThumb::default(), Hovered(false), ChildOf(track)));
1161
1162        world.run_system_once(bridge_scrollbar_capture).unwrap();
1163        let capture = *world.resource::<PointerCapture>();
1164        assert!(
1165            capture.over_ui,
1166            "hovering the track must claim the hover channel"
1167        );
1168        assert!(!capture.dragging, "hover alone is not a drag claim");
1169
1170        // No part hovered → no claim (world input stays free). `Hovered` is an
1171        // immutable component — replace it wholesale.
1172        world.entity_mut(track).insert(Hovered(false));
1173        world.insert_resource(PointerCapture::default());
1174        world.run_system_once(bridge_scrollbar_capture).unwrap();
1175        assert!(!world.resource::<PointerCapture>().over_ui);
1176    }
1177}