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::{BorderColorSpec, Rect};
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
723pub fn bridge_scrollbar_capture(
729 q_drag: Query<(&ScrollbarDragState, &ChildOf), With<ScrollbarThumb>>,
730 q_scrollbar: Query<&Scrollbar>,
731 mut q_scroll: Query<(&ScrollPosition, &mut ScrollTransitionState)>,
732 mut capture: ResMut<PointerCapture>,
733) {
734 for (drag, child_of) in &q_drag {
735 if !drag.dragging {
736 continue;
737 }
738 capture.dragging = true;
739 capture.over_ui = true;
740 if let Ok(scrollbar) = q_scrollbar.get(child_of.parent())
741 && let Ok((pos, mut state)) = q_scroll.get_mut(scrollbar.target)
742 {
743 state.target = pos.0;
744 }
745 }
746}
747
748fn part_state(dragging: bool, hovered: bool) -> PartState {
751 if dragging {
752 PartState::Pressed
753 } else if hovered {
754 PartState::Hover
755 } else {
756 PartState::Base
757 }
758}
759
760#[allow(clippy::type_complexity)]
765pub fn style_scrollbar_states(
766 tracks: Res<ScrollbarTracks>,
767 q_config: Query<&ScrollbarConfig>,
768 q_hovered: Query<&Hovered>,
769 q_drag: Query<&ScrollbarDragState, With<ScrollbarThumb>>,
770 mut q_track: Query<
771 (&mut BackgroundColor, &mut BorderColor, &mut Node),
772 (With<Scrollbar>, Without<ScrollbarThumb>),
773 >,
774 mut q_thumb: Query<
775 (&mut BackgroundColor, &mut BorderColor, &mut ScrollbarThumb),
776 (With<ScrollbarThumb>, Without<Scrollbar>),
777 >,
778) {
779 for (&container, entry) in tracks.0.iter() {
780 let Ok(config) = q_config.get(container) else {
781 continue;
782 };
783 let spec = &config.0;
784 let thickness = spec.thickness();
785
786 for axis in [entry.vertical, entry.horizontal].into_iter().flatten() {
787 let dragging = q_drag.get(axis.thumb).map(|d| d.dragging).unwrap_or(false);
789
790 if let Ok((mut bg, mut bc, mut node)) = q_track.get_mut(axis.track) {
791 let hovered = q_hovered.get(axis.track).map(|h| h.0).unwrap_or(false);
792 let v = resolve_visual(
793 spec.track_style(),
794 part_state(dragging, hovered),
795 DEFAULT_TRACK_COLOR,
796 BorderRadius::ZERO,
797 );
798 bg.set_if_neq(v.background);
799 bc.set_if_neq(v.border_color);
800 if node.border != v.border || node.border_radius != v.border_radius {
803 node.border = v.border;
804 node.border_radius = v.border_radius;
805 }
806 }
807
808 if let Ok((mut bg, mut bc, mut thumb)) = q_thumb.get_mut(axis.thumb) {
809 let hovered = q_hovered.get(axis.thumb).map(|h| h.0).unwrap_or(false);
810 let v = resolve_visual(
811 spec.thumb_style(),
812 part_state(dragging, hovered),
813 DEFAULT_THUMB_COLOR,
814 thumb_default_radius(thickness),
815 );
816 bg.set_if_neq(v.background);
817 bc.set_if_neq(v.border_color);
818 if thumb.border != v.border || thumb.border_radius != v.border_radius {
819 thumb.border = v.border;
820 thumb.border_radius = v.border_radius;
821 }
822 }
823 }
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use crate::protocol::Style;
831
832 #[test]
833 fn decodes_none_and_default_keywords() {
834 let style: Style = serde_json::from_value(serde_json::json!({ "scrollbar": "none" }))
835 .expect("decode none");
836 assert_eq!(style.scrollbar, Some(ScrollbarSpec::None));
837
838 let style: Style = serde_json::from_value(serde_json::json!({ "scrollbar": "default" }))
839 .expect("decode default");
840 assert_eq!(style.scrollbar, Some(ScrollbarSpec::Default));
841 }
842
843 #[test]
844 fn decodes_styled_object_round_trip() {
845 let style: Style = serde_json::from_value(serde_json::json!({
846 "scrollbar": {
847 "track": { "backgroundColor": "#111111", "borderRadius": 4 },
848 "thumb": { "backgroundColor": "#888888" },
849 "thickness": 8,
850 "minThumbLength": 30,
851 "position": "float",
852 "verticalSide": "left",
853 "horizontalSide": "top",
854 }
855 }))
856 .expect("decode styled");
857 let spec = style.scrollbar.expect("present");
858 assert_eq!(spec.thickness(), 8.0);
859 assert_eq!(spec.min_thumb_length(), 30.0);
860 assert_eq!(spec.position(), ScrollbarPosition::Float);
861 assert_eq!(spec.vertical_side(), HorizontalEdge::Left);
862 assert_eq!(spec.horizontal_side(), VerticalEdge::Top);
863 let track = spec.track_style().expect("track");
864 assert_eq!(track.background_color.as_deref(), Some("#111111"));
865 assert!(spec.thumb_style().is_some());
866 }
867
868 #[test]
869 fn unknown_keyword_falls_back_to_none() {
870 let style: Style = serde_json::from_value(serde_json::json!({ "scrollbar": "wat" }))
871 .expect("must not error on a bad keyword");
872 assert_eq!(style.scrollbar, Some(ScrollbarSpec::None));
873 }
874
875 #[test]
876 fn decodes_hover_and_pressed_variants() {
877 let style: Style = serde_json::from_value(serde_json::json!({
878 "scrollbar": {
879 "thumb": {
880 "backgroundColor": "#888888",
881 "hover": { "backgroundColor": "#aaaaaa" },
882 "pressed": { "backgroundColor": "#c4b5fd" },
883 }
884 }
885 }))
886 .expect("decode variants");
887 let spec = style.scrollbar.expect("present");
888 let thumb = spec.thumb_style().expect("thumb");
889 assert_eq!(thumb.background_color.as_deref(), Some("#888888"));
890 assert_eq!(
891 thumb
892 .variant(PartState::Hover)
893 .unwrap()
894 .background_color
895 .as_deref(),
896 Some("#aaaaaa")
897 );
898 assert_eq!(
899 thumb
900 .variant(PartState::Pressed)
901 .unwrap()
902 .background_color
903 .as_deref(),
904 Some("#c4b5fd")
905 );
906 }
907
908 #[test]
909 fn resolve_visual_precedence_pressed_over_hover_over_base() {
910 let part = ScrollbarPartStyle {
912 background_color: Some("#0000ff".into()),
913 border_radius: Some(Rect::default()),
914 hover: Some(Box::new(ScrollbarPartStyle {
915 background_color: Some("#00ff00".into()),
916 ..default()
917 })),
918 pressed: Some(Box::new(ScrollbarPartStyle {
919 border_radius: Some(Rect::default()),
920 ..default()
921 })),
922 ..default()
923 };
924 let base = resolve_visual(
925 Some(&part),
926 PartState::Base,
927 Color::WHITE,
928 BorderRadius::ZERO,
929 );
930 assert_eq!(base.background.0, parse_color("#0000ff"));
931
932 let hover = resolve_visual(
933 Some(&part),
934 PartState::Hover,
935 Color::WHITE,
936 BorderRadius::ZERO,
937 );
938 assert_eq!(
939 hover.background.0,
940 parse_color("#00ff00"),
941 "hover overrides base"
942 );
943
944 let pressed = resolve_visual(
946 Some(&part),
947 PartState::Pressed,
948 Color::WHITE,
949 BorderRadius::ZERO,
950 );
951 assert_eq!(
952 pressed.background.0,
953 parse_color("#0000ff"),
954 "pressed with no color falls back to base, not hover"
955 );
956
957 let none = resolve_visual(None, PartState::Hover, Color::WHITE, BorderRadius::ZERO);
959 assert_eq!(none.background.0, Color::WHITE);
960 }
961
962 #[test]
963 fn defaults_when_object_omits_fields() {
964 let style: Style =
965 serde_json::from_value(serde_json::json!({ "scrollbar": {} })).expect("decode empty");
966 let spec = style.scrollbar.expect("present");
967 assert_eq!(spec.thickness(), DEFAULT_THICKNESS);
968 assert_eq!(spec.min_thumb_length(), DEFAULT_MIN_THUMB);
969 assert_eq!(spec.position(), ScrollbarPosition::Gutter);
970 assert_eq!(spec.vertical_side(), HorizontalEdge::Right);
971 assert_eq!(spec.horizontal_side(), VerticalEdge::Bottom);
972 }
973
974 #[test]
975 fn scroll_max_is_zero_when_content_fits() {
976 let fits = ComputedNode {
977 size: Vec2::new(100.0, 100.0),
978 content_size: Vec2::new(100.0, 100.0),
979 inverse_scale_factor: 1.0,
980 ..default()
981 };
982 assert_eq!(scroll_max(&fits), Vec2::ZERO);
983
984 let overflowing = ComputedNode {
985 size: Vec2::new(100.0, 100.0),
986 content_size: Vec2::new(100.0, 300.0),
987 inverse_scale_factor: 1.0,
988 ..default()
989 };
990 assert_eq!(scroll_max(&overflowing), Vec2::new(0.0, 200.0));
991 }
992
993 #[test]
994 fn places_vertical_track_on_the_right_edge() {
995 let (pos, dims) = place_track(
997 Vec2::new(10.0, 20.0),
998 Vec2::new(200.0, 100.0),
999 12.0,
1000 ControlOrientation::Vertical,
1001 HorizontalEdge::Right,
1002 VerticalEdge::Bottom,
1003 );
1004 assert_eq!(pos, Vec2::new(10.0 + 200.0 - 12.0, 20.0));
1005 assert_eq!(dims, Vec2::new(12.0, 100.0));
1006 }
1007
1008 #[test]
1009 fn places_horizontal_track_on_the_top_edge() {
1010 let (pos, dims) = place_track(
1011 Vec2::new(10.0, 20.0),
1012 Vec2::new(200.0, 100.0),
1013 12.0,
1014 ControlOrientation::Horizontal,
1015 HorizontalEdge::Right,
1016 VerticalEdge::Top,
1017 );
1018 assert_eq!(pos, Vec2::new(10.0, 20.0));
1019 assert_eq!(dims, Vec2::new(200.0, 12.0));
1020 }
1021}