1use 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
43pub const DEFAULT_THICKNESS: f32 = 12.0;
45pub const DEFAULT_MIN_THUMB: f32 = 24.0;
48const DEFAULT_TRACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.12);
50const DEFAULT_THUMB_COLOR: Color = Color::srgba(0.55, 0.55, 0.55, 0.9);
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum ScrollbarPosition {
56 #[default]
59 Gutter,
60 Float,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum HorizontalEdge {
67 Left,
68 #[default]
69 Right,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum VerticalEdge {
75 Top,
76 #[default]
77 Bottom,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum PartState {
84 Base,
85 Hover,
86 Pressed,
87}
88
89#[derive(Debug, Clone, Default, PartialEq)]
99pub struct ScrollbarPartStyle {
100 pub background_color: Option<String>,
102 pub border_color: Option<BorderColorSpec>,
104 pub border_radius: Option<Rect>,
106 pub border: Option<Rect>,
108 pub hover: Option<Box<ScrollbarPartStyle>>,
110 pub pressed: Option<Box<ScrollbarPartStyle>>,
112}
113
114impl ScrollbarPartStyle {
115 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 _ => {
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#[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#[derive(Debug, Clone, PartialEq, Default)]
189pub enum ScrollbarSpec {
190 #[default]
192 None,
193 Default,
195 Styled(Box<ScrollbarStyled>),
197}
198
199impl ScrollbarSpec {
200 pub fn is_visible(&self) -> bool {
202 !matches!(self, ScrollbarSpec::None)
203 }
204
205 fn styled(&self) -> Option<&ScrollbarStyled> {
207 match self {
208 ScrollbarSpec::Styled(s) => Some(s),
209 _ => None,
210 }
211 }
212
213 pub fn thickness(&self) -> f32 {
215 self.styled()
216 .and_then(|s| s.thickness)
217 .unwrap_or(DEFAULT_THICKNESS)
218 }
219
220 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 pub fn position(&self) -> ScrollbarPosition {
229 self.styled().map(|s| s.position).unwrap_or_default()
230 }
231
232 pub fn vertical_side(&self) -> HorizontalEdge {
234 self.styled().map(|s| s.vertical_side).unwrap_or_default()
235 }
236
237 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 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#[derive(Component, Debug, Clone, PartialEq)]
371pub struct ScrollbarConfig(pub ScrollbarSpec);
372
373#[derive(Clone, Copy)]
375struct AxisEntities {
376 track: Entity,
377 #[allow(dead_code)] thumb: Entity,
379}
380
381struct ContainerTracks {
383 vertical: Option<AxisEntities>,
384 horizontal: Option<AxisEntities>,
385 spec: ScrollbarSpec,
387}
388
389#[derive(Resource, Default)]
393pub struct ScrollbarTracks(HashMap<Entity, ContainerTracks>);
394
395struct EffectiveVisual {
399 background: BackgroundColor,
400 border_color: BorderColor,
401 border: UiRect,
402 border_radius: BorderRadius,
403}
404
405fn 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
450fn thumb_default_radius(thickness: f32) -> BorderRadius {
452 BorderRadius::all(Val::Px(thickness * 0.5))
453}
454
455fn 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 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 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 ZIndex(i32::MAX),
493 Visibility::Hidden,
495 Pickable {
498 should_block_lower: true,
499 is_hoverable: true,
500 },
501 Hovered::default(),
502 ChildOf(parent),
503 ))
504 .id();
505 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
527pub 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 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 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
603fn scroll_max(computed: &ComputedNode) -> Vec2 {
606 (computed.content_size - computed.size + computed.scrollbar_size).max(Vec2::ZERO)
607 * computed.inverse_scale_factor
608}
609
610fn 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#[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 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 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#[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
758fn 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#[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 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 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 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 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 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 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 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 #[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 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 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 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 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 #[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 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}