1use super::*;
2use crate::bindings::ui;
3use crate::event::PointerEventArgs;
4use crate::ffi::{CursorStyle, Orientation, Unit};
5use crate::platform;
6use crate::signal::Subscription;
7use crate::theme::{current_theme, subscribe, Theme};
8use std::cell::{Cell, RefCell};
9use std::rc::{Rc, Weak};
10
11const DEFAULT_TRACK_THICKNESS: f32 = 8.0;
12const DEFAULT_THUMB_THICKNESS: f32 = 8.0;
13const DEFAULT_MIN_THUMB_SIZE: f32 = 18.0;
14
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct ScrollBarStyle {
17 track_width: f32,
18 thumb_width: f32,
19 thumb_min_height: f32,
20 track_corner_radius: f32,
21 thumb_corner_radius: f32,
22 track_color: Option<u32>,
23 thumb_color: Option<u32>,
24}
25
26impl ScrollBarStyle {
27 pub fn new() -> Self {
28 Self::default()
29 }
30
31 pub fn track_width(mut self, value: f32) -> Self {
32 self.track_width = value;
33 self
34 }
35
36 pub fn thumb_width(mut self, value: f32) -> Self {
37 self.thumb_width = value;
38 self
39 }
40
41 pub fn thumb_min_height(mut self, value: f32) -> Self {
42 self.thumb_min_height = value;
43 self
44 }
45
46 pub fn track_corner_radius(mut self, value: f32) -> Self {
47 self.track_corner_radius = value;
48 self
49 }
50
51 pub fn thumb_corner_radius(mut self, value: f32) -> Self {
52 self.thumb_corner_radius = value;
53 self
54 }
55
56 pub fn track_color(mut self, value: u32) -> Self {
57 self.track_color = Some(value);
58 self
59 }
60
61 pub fn thumb_color(mut self, value: u32) -> Self {
62 self.thumb_color = Some(value);
63 self
64 }
65
66 pub(crate) fn apply_to(self, scroll_bar: &ScrollBar) {
67 scroll_bar
68 .track_width(self.track_width)
69 .thumb_width(self.thumb_width)
70 .thumb_min_height(self.thumb_min_height)
71 .track_corner_radius(self.track_corner_radius)
72 .thumb_corner_radius(self.thumb_corner_radius);
73 if let Some(color) = self.track_color {
74 scroll_bar.track_color(color);
75 } else {
76 scroll_bar.clear_track_color();
77 }
78 if let Some(color) = self.thumb_color {
79 scroll_bar.thumb_color(color);
80 } else {
81 scroll_bar.clear_thumb_color();
82 }
83 }
84}
85
86impl Default for ScrollBarStyle {
87 fn default() -> Self {
88 Self {
89 track_width: DEFAULT_TRACK_THICKNESS,
90 thumb_width: DEFAULT_THUMB_THICKNESS,
91 thumb_min_height: DEFAULT_MIN_THUMB_SIZE,
92 track_corner_radius: 0.0,
93 thumb_corner_radius: 0.0,
94 track_color: None,
95 thumb_color: None,
96 }
97 }
98}
99
100fn clamp(value: f32, min: f32, max: f32) -> f32 {
101 if value < min {
102 return min;
103 }
104 if value > max {
105 return max;
106 }
107 value
108}
109
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum ScrollBarVisibility {
112 Always,
113 Auto,
114 Never,
115}
116
117#[derive(Clone, Copy, Default)]
118struct ScrollMetrics {
119 viewport_size: f32,
120 max_offset: f32,
121 thumb_size: f32,
122 max_thumb_offset: f32,
123 thumb_offset: f32,
124 trailing_spacer_size: f32,
125}
126
127struct ScrollBarInner {
128 scroll_state: ScrollState,
129 orientation: Orientation,
130 target_handle: Cell<u64>,
131 drag_start_axis: Cell<f32>,
132 drag_start_offset: Cell<f32>,
133 dragging: Cell<bool>,
134 chrome_visible: Cell<bool>,
135 track_thickness: Cell<f32>,
136 thumb_thickness: Cell<f32>,
137 min_thumb_size: Cell<f32>,
138 track_corner_radius: Cell<f32>,
139 thumb_corner_radius: Cell<f32>,
140 track_color: Cell<u32>,
141 thumb_color: Cell<u32>,
142 track_color_overridden: Cell<bool>,
143 thumb_color_overridden: Cell<bool>,
144 track_node: FlexBox,
145 track_strip: FlexBox,
146 leading_spacer_node: FlexBox,
147 thumb_node: FlexBox,
148 trailing_spacer_node: FlexBox,
149 subscriptions: RefCell<Vec<Subscription>>,
150 chrome_metric_changed: RefCell<Option<Rc<dyn Fn()>>>,
151}
152
153#[derive(Clone)]
154pub struct ScrollBar {
161 inner: Rc<ScrollBarInner>,
162}
163
164impl ScrollBar {
165 pub fn new(scroll_state: ScrollState, orientation: Orientation) -> Self {
166 let orientation = if orientation == Orientation::Horizontal {
167 Orientation::Horizontal
168 } else {
169 Orientation::Vertical
170 };
171 let track_node = flex_box();
172 let track_strip = if orientation == Orientation::Horizontal {
173 row()
174 } else {
175 column()
176 };
177 let leading_spacer_node = flex_box();
178 let thumb_node = flex_box();
179 let trailing_spacer_node = flex_box();
180 track_strip
181 .align_items(crate::ffi::AlignItems::Center)
182 .child(&leading_spacer_node)
183 .child(&thumb_node)
184 .child(&trailing_spacer_node);
185 let interactive = !platform::is_coarse_pointer();
186 track_node.clip_to_bounds(true).child(&track_strip);
187 if interactive {
188 track_node.on_pointer_down(|_event: &mut PointerEventArgs| {});
189 thumb_node
190 .on_pointer_down(|_event: &mut PointerEventArgs| {})
191 .on_pointer_enter(|_event: &mut PointerEventArgs| {})
192 .on_pointer_leave(|_event: &mut PointerEventArgs| {})
193 .on_pointer_move(|_event: &mut PointerEventArgs| {})
194 .on_pointer_up(|_event: &mut PointerEventArgs| {});
195 }
196
197 let theme = current_theme();
198 let inner = Rc::new(ScrollBarInner {
199 scroll_state,
200 orientation,
201 target_handle: Cell::new(HandleValue::Invalid as u64),
202 drag_start_axis: Cell::new(0.0),
203 drag_start_offset: Cell::new(0.0),
204 dragging: Cell::new(false),
205 chrome_visible: Cell::new(true),
206 track_thickness: Cell::new(DEFAULT_TRACK_THICKNESS),
207 thumb_thickness: Cell::new(DEFAULT_THUMB_THICKNESS),
208 min_thumb_size: Cell::new(DEFAULT_MIN_THUMB_SIZE),
209 track_corner_radius: Cell::new(0.0),
210 thumb_corner_radius: Cell::new(0.0),
211 track_color: Cell::new(theme.colors.scrollbar_track),
212 thumb_color: Cell::new(theme.colors.scrollbar_thumb),
213 track_color_overridden: Cell::new(false),
214 thumb_color_overridden: Cell::new(false),
215 track_node,
216 track_strip,
217 leading_spacer_node,
218 thumb_node,
219 trailing_spacer_node,
220 subscriptions: RefCell::new(Vec::new()),
221 chrome_metric_changed: RefCell::new(None),
222 });
223 let bar = Self { inner };
224 if interactive {
225 bar.attach_handlers();
226 }
227 bar.attach_listeners();
228 bar.apply_geometry_style();
229 bar.apply_color_style();
230 bar.sync_visual_state();
231 bar.apply_thumb_cursor();
232 bar
233 }
234
235 pub fn thickness(&self) -> f32 {
236 self.inner.track_thickness.get()
237 }
238
239 pub fn render(&self) -> FlexBox {
240 self.inner.track_node.clone()
241 }
242
243 pub fn track_width(&self, value: f32) -> &Self {
244 self.set_track_thickness(value);
245 self
246 }
247
248 pub fn track_thickness(&self, value: f32) -> &Self {
249 self.set_track_thickness(value);
250 self
251 }
252
253 pub fn thumb_width(&self, value: f32) -> &Self {
254 self.set_thumb_thickness(value);
255 self
256 }
257
258 pub fn thumb_thickness(&self, value: f32) -> &Self {
259 self.set_thumb_thickness(value);
260 self
261 }
262
263 pub fn thumb_min_height(&self, value: f32) -> &Self {
264 self.set_thumb_min_size(value);
265 self
266 }
267
268 fn set_track_thickness(&self, value: f32) {
269 let next = if value > 0.0 { value } else { 1.0 };
270 if value <= 0.0 {
271 crate::logger::warn(
272 "Layout",
273 &format!("ScrollBar.trackThickness() received {value}; clamping to 1.0."),
274 );
275 }
276 if self.inner.track_thickness.get() == next {
277 return;
278 }
279 self.inner.track_thickness.set(next);
280 self.apply_geometry_style();
281 self.notify_chrome_metric_changed();
282 }
283
284 fn set_thumb_thickness(&self, value: f32) {
285 let next = if value > 0.0 { value } else { 1.0 };
286 if value <= 0.0 {
287 crate::logger::warn(
288 "Layout",
289 &format!("ScrollBar.thumbThickness() received {value}; clamping to 1.0."),
290 );
291 }
292 self.inner.thumb_thickness.set(next);
293 self.apply_geometry_style();
294 }
295
296 fn set_thumb_min_size(&self, value: f32) {
297 let next = if value > 0.0 { value } else { 1.0 };
298 if value <= 0.0 {
299 crate::logger::warn(
300 "Layout",
301 &format!("ScrollBar.thumbMinHeight() received {value}; clamping to 1.0."),
302 );
303 }
304 self.inner.min_thumb_size.set(next);
305 self.sync_visual_state();
306 }
307
308 pub fn track_corner_radius(&self, radius: f32) -> &Self {
309 self.inner.track_corner_radius.set(radius.max(0.0));
310 self.inner
311 .track_node
312 .corner_radius(self.inner.track_corner_radius.get());
313 self
314 }
315
316 pub fn thumb_corner_radius(&self, radius: f32) -> &Self {
317 self.inner.thumb_corner_radius.set(radius.max(0.0));
318 self.inner
319 .thumb_node
320 .corner_radius(self.inner.thumb_corner_radius.get());
321 self
322 }
323
324 pub fn track_color(&self, color: u32) -> &Self {
325 self.inner.track_color_overridden.set(true);
326 self.inner.track_color.set(color);
327 self.apply_color_style();
328 self
329 }
330
331 fn clear_track_color(&self) {
332 self.inner.track_color_overridden.set(false);
333 self.inner
334 .track_color
335 .set(current_theme().colors.scrollbar_track);
336 self.apply_color_style();
337 }
338
339 pub fn thumb_color(&self, color: u32) -> &Self {
340 self.inner.thumb_color_overridden.set(true);
341 self.inner.thumb_color.set(color);
342 self.apply_color_style();
343 self
344 }
345
346 fn clear_thumb_color(&self) {
347 self.inner.thumb_color_overridden.set(false);
348 self.inner
349 .thumb_color
350 .set(current_theme().colors.scrollbar_thumb);
351 self.apply_color_style();
352 }
353
354 pub fn bind_scroll_handle(&self, handle: u64) {
355 self.inner.target_handle.set(handle);
356 self.inner
357 .track_node
358 .bind_scroll_proxy_target_handle(handle);
359 self.inner
360 .track_strip
361 .bind_scroll_proxy_target_handle(handle);
362 self.inner
363 .leading_spacer_node
364 .bind_scroll_proxy_target_handle(handle);
365 self.inner
366 .thumb_node
367 .bind_scroll_proxy_target_handle(handle);
368 self.inner
369 .trailing_spacer_node
370 .bind_scroll_proxy_target_handle(handle);
371 }
372
373 pub fn clear_scroll_handle(&self, handle: u64) {
374 if self.inner.target_handle.get() == handle {
375 self.bind_scroll_handle(HandleValue::Invalid as u64);
376 }
377 }
378
379 pub fn chrome_visible(&self, visible: bool) {
380 if self.inner.chrome_visible.get() == visible {
381 return;
382 }
383 self.inner.chrome_visible.set(visible);
384 if !visible {
385 self.end_drag();
386 }
387 self.apply_geometry_style();
388 self.sync_visual_state();
389 self.apply_thumb_cursor();
390 }
391
392 pub fn refresh_now(&self) {
393 self.sync_visual_state();
394 }
395
396 pub fn can_start_thumb_drag(&self) -> bool {
397 let metrics = self.compute_metrics();
398 self.inner.chrome_visible.get()
399 && metrics.max_offset > 0.0
400 && metrics.max_thumb_offset > 0.0
401 }
402
403 pub(crate) fn set_chrome_metric_changed_handler(&self, handler: impl Fn() + 'static) {
404 *self.inner.chrome_metric_changed.borrow_mut() = Some(Rc::new(handler));
405 }
406
407 fn attach_handlers(&self) {
408 let weak = Rc::downgrade(&self.inner);
409 self.inner.track_node.on_pointer_down(move |event| {
410 if let Some(inner) = Weak::upgrade(&weak) {
411 ScrollBar { inner }.handle_track_pointer_down(event.scene_x, event.scene_y);
412 }
413 });
414
415 let weak = Rc::downgrade(&self.inner);
416 self.inner.thumb_node.on_pointer_down(move |event| {
417 if let Some(inner) = Weak::upgrade(&weak) {
418 let bar = ScrollBar { inner };
419 if bar.can_start_thumb_drag() {
420 bar.inner.dragging.set(true);
421 bar.inner
422 .drag_start_axis
423 .set(bar.axis_position(event.scene_x, event.scene_y));
424 bar.inner.drag_start_offset.set(bar.axis_offset());
425 event.capture_pointer();
426 event.handled = true;
427 bar.apply_thumb_cursor();
428 }
429 }
430 });
431
432 let weak = Rc::downgrade(&self.inner);
433 self.inner.thumb_node.on_pointer_move(move |event| {
434 if let Some(inner) = Weak::upgrade(&weak) {
435 let bar = ScrollBar { inner };
436 if !bar.inner.dragging.get() {
437 return;
438 }
439 let metrics = bar.compute_metrics();
440 if metrics.max_offset <= 0.0 || metrics.max_thumb_offset <= 0.0 {
441 return;
442 }
443 let delta = bar.axis_position(event.scene_x, event.scene_y)
444 - bar.inner.drag_start_axis.get();
445 let offset_per_thumb_pixel = metrics.max_offset / metrics.max_thumb_offset;
446 bar.set_scroll_offset(
447 bar.inner.drag_start_offset.get() + (delta * offset_per_thumb_pixel),
448 metrics.max_offset,
449 );
450 event.handled = true;
451 }
452 });
453
454 let weak = Rc::downgrade(&self.inner);
455 self.inner.thumb_node.on_pointer_up(move |event| {
456 if let Some(inner) = Weak::upgrade(&weak) {
457 let bar = ScrollBar { inner };
458 if bar.inner.dragging.get() {
459 bar.end_drag();
460 event.handled = true;
461 }
462 }
463 });
464 }
465
466 fn attach_listeners(&self) {
467 let weak = Rc::downgrade(&self.inner);
468 self.inner
469 .subscriptions
470 .borrow_mut()
471 .push(self.inner.scroll_state.subscribe_offset_x(move || {
472 if let Some(inner) = Weak::upgrade(&weak) {
473 ScrollBar { inner }.sync_visual_state();
474 }
475 }));
476 let weak = Rc::downgrade(&self.inner);
477 self.inner
478 .subscriptions
479 .borrow_mut()
480 .push(self.inner.scroll_state.subscribe_offset_y(move || {
481 if let Some(inner) = Weak::upgrade(&weak) {
482 ScrollBar { inner }.sync_visual_state();
483 }
484 }));
485 let weak = Rc::downgrade(&self.inner);
486 self.inner.subscriptions.borrow_mut().push(
487 self.inner.scroll_state.subscribe_content_width(move || {
488 if let Some(inner) = Weak::upgrade(&weak) {
489 ScrollBar { inner }.sync_visual_state();
490 }
491 }),
492 );
493 let weak = Rc::downgrade(&self.inner);
494 self.inner.subscriptions.borrow_mut().push(
495 self.inner.scroll_state.subscribe_content_height(move || {
496 if let Some(inner) = Weak::upgrade(&weak) {
497 ScrollBar { inner }.sync_visual_state();
498 }
499 }),
500 );
501 let weak = Rc::downgrade(&self.inner);
502 self.inner.subscriptions.borrow_mut().push(
503 self.inner.scroll_state.subscribe_viewport_width(move || {
504 if let Some(inner) = Weak::upgrade(&weak) {
505 ScrollBar { inner }.sync_visual_state();
506 }
507 }),
508 );
509 let weak = Rc::downgrade(&self.inner);
510 self.inner.subscriptions.borrow_mut().push(
511 self.inner.scroll_state.subscribe_viewport_height(move || {
512 if let Some(inner) = Weak::upgrade(&weak) {
513 ScrollBar { inner }.sync_visual_state();
514 }
515 }),
516 );
517 let weak = Rc::downgrade(&self.inner);
518 self.inner
519 .subscriptions
520 .borrow_mut()
521 .push(subscribe(move |theme| {
522 if let Some(inner) = Weak::upgrade(&weak) {
523 ScrollBar { inner }.handle_theme_changed(theme);
524 }
525 }));
526 }
527
528 fn handle_theme_changed(&self, theme: Theme) {
529 if !self.inner.track_color_overridden.get() {
530 self.inner.track_color.set(theme.colors.scrollbar_track);
531 }
532 if !self.inner.thumb_color_overridden.get() {
533 self.inner.thumb_color.set(theme.colors.scrollbar_thumb);
534 }
535 self.apply_color_style();
536 }
537
538 fn notify_chrome_metric_changed(&self) {
539 if let Some(handler) = self.inner.chrome_metric_changed.borrow().clone() {
540 handler();
541 }
542 }
543
544 fn end_drag(&self) {
545 self.inner.dragging.set(false);
546 crate::event::release_pointer(self.inner.thumb_node.handle());
547 unsafe { crate::ffi::fui_release_pointer_capture() };
548 self.apply_thumb_cursor();
549 }
550
551 fn compute_metrics(&self) -> ScrollMetrics {
552 let viewport_size = if self.inner.orientation == Orientation::Horizontal {
553 self.inner.scroll_state.viewport_width()
554 } else {
555 self.inner.scroll_state.viewport_height()
556 }
557 .max(0.0);
558 let content_size = if self.inner.orientation == Orientation::Horizontal {
559 self.inner.scroll_state.content_width()
560 } else {
561 self.inner.scroll_state.content_height()
562 }
563 .max(viewport_size);
564 let max_offset = content_size - viewport_size;
565 let raw_thumb_size = if content_size > 0.0 {
566 viewport_size * (viewport_size / content_size)
567 } else {
568 0.0
569 };
570 let thumb_size = if viewport_size > 0.0 {
571 clamp(
572 raw_thumb_size,
573 self.inner.min_thumb_size.get(),
574 viewport_size,
575 )
576 } else {
577 0.0
578 };
579 let max_thumb_offset = if viewport_size > thumb_size {
580 viewport_size - thumb_size
581 } else {
582 0.0
583 };
584 let offset = self.axis_offset();
585 let thumb_offset = if max_offset > 0.0 && max_thumb_offset > 0.0 {
586 clamp(
587 (offset / max_offset) * max_thumb_offset,
588 0.0,
589 max_thumb_offset,
590 )
591 } else {
592 0.0
593 };
594 ScrollMetrics {
595 viewport_size,
596 max_offset,
597 thumb_size,
598 max_thumb_offset,
599 thumb_offset,
600 trailing_spacer_size: (viewport_size - thumb_size - thumb_offset).max(0.0),
601 }
602 }
603
604 fn sync_visual_state(&self) {
605 if !self.inner.chrome_visible.get() {
606 if self.inner.orientation == Orientation::Horizontal {
607 self.inner.track_node.width(0.0, Unit::Pixel);
608 self.inner.track_strip.width(0.0, Unit::Pixel);
609 self.inner.leading_spacer_node.width(0.0, Unit::Pixel);
610 self.inner.thumb_node.width(0.0, Unit::Pixel);
611 self.inner.trailing_spacer_node.width(0.0, Unit::Pixel);
612 } else {
613 self.inner.track_node.height(0.0, Unit::Pixel);
614 self.inner.track_strip.height(0.0, Unit::Pixel);
615 self.inner.leading_spacer_node.height(0.0, Unit::Pixel);
616 self.inner.thumb_node.height(0.0, Unit::Pixel);
617 self.inner.trailing_spacer_node.height(0.0, Unit::Pixel);
618 }
619 return;
620 }
621
622 let metrics = self.compute_metrics();
623 if self.inner.orientation == Orientation::Horizontal {
624 self.inner
625 .track_node
626 .width(metrics.viewport_size, Unit::Pixel);
627 self.inner
628 .track_strip
629 .width(metrics.viewport_size, Unit::Pixel);
630 self.inner
631 .leading_spacer_node
632 .width(metrics.thumb_offset, Unit::Pixel);
633 self.inner.thumb_node.width(metrics.thumb_size, Unit::Pixel);
634 self.inner
635 .trailing_spacer_node
636 .width(metrics.trailing_spacer_size, Unit::Pixel);
637 } else {
638 self.inner
639 .track_node
640 .height(metrics.viewport_size, Unit::Pixel);
641 self.inner
642 .track_strip
643 .height(metrics.viewport_size, Unit::Pixel);
644 self.inner
645 .leading_spacer_node
646 .height(metrics.thumb_offset, Unit::Pixel);
647 self.inner
648 .thumb_node
649 .height(metrics.thumb_size, Unit::Pixel);
650 self.inner
651 .trailing_spacer_node
652 .height(metrics.trailing_spacer_size, Unit::Pixel);
653 }
654 }
655
656 fn apply_geometry_style(&self) {
657 let track_thickness = if self.inner.chrome_visible.get() {
658 self.inner.track_thickness.get()
659 } else {
660 0.0
661 };
662 let thumb_thickness = if self.inner.chrome_visible.get() {
663 self.inner.thumb_thickness.get()
664 } else {
665 0.0
666 };
667 if self.inner.orientation == Orientation::Horizontal {
668 self.inner.track_node.height(track_thickness, Unit::Pixel);
669 self.inner.track_strip.height(track_thickness, Unit::Pixel);
670 self.inner
671 .leading_spacer_node
672 .height(track_thickness, Unit::Pixel);
673 self.inner
674 .trailing_spacer_node
675 .height(track_thickness, Unit::Pixel);
676 self.inner.thumb_node.height(thumb_thickness, Unit::Pixel);
677 } else {
678 self.inner.track_node.width(track_thickness, Unit::Pixel);
679 self.inner.track_strip.width(track_thickness, Unit::Pixel);
680 self.inner
681 .leading_spacer_node
682 .width(track_thickness, Unit::Pixel);
683 self.inner
684 .trailing_spacer_node
685 .width(track_thickness, Unit::Pixel);
686 self.inner.thumb_node.width(thumb_thickness, Unit::Pixel);
687 }
688 self.inner
689 .track_node
690 .corner_radius(self.inner.track_corner_radius.get());
691 self.inner
692 .thumb_node
693 .corner_radius(self.inner.thumb_corner_radius.get());
694 }
695
696 fn apply_color_style(&self) {
697 self.inner.track_node.bg_color(self.inner.track_color.get());
698 self.inner.thumb_node.bg_color(self.inner.thumb_color.get());
699 }
700
701 fn apply_thumb_cursor(&self) {
702 if platform::is_coarse_pointer() {
703 return;
704 }
705 self.inner
706 .thumb_node
707 .cursor(if !self.inner.chrome_visible.get() {
708 CursorStyle::Default
709 } else if self.inner.dragging.get() {
710 CursorStyle::Grabbing
711 } else {
712 CursorStyle::Grab
713 });
714 }
715
716 fn axis_position(&self, x: f32, y: f32) -> f32 {
717 if self.inner.orientation == Orientation::Horizontal {
718 x
719 } else {
720 y
721 }
722 }
723
724 fn axis_offset(&self) -> f32 {
725 if self.inner.orientation == Orientation::Horizontal {
726 self.inner.scroll_state.offset_x()
727 } else {
728 self.inner.scroll_state.offset_y()
729 }
730 }
731
732 fn handle_track_pointer_down(&self, pointer_x: f32, pointer_y: f32) {
733 if self.inner.dragging.get() {
734 return;
735 }
736 let metrics = self.compute_metrics();
737 if metrics.max_offset <= 0.0 || metrics.max_thumb_offset <= 0.0 {
738 return;
739 }
740 let Some(bounds) = ui::get_bounds(self.inner.track_node.handle().raw()) else {
741 return;
742 };
743 let local_pointer = if self.inner.orientation == Orientation::Horizontal {
744 pointer_x - bounds[0]
745 } else {
746 pointer_y - bounds[1]
747 };
748 let target_thumb_offset = clamp(
749 local_pointer - (metrics.thumb_size * 0.5),
750 0.0,
751 metrics.max_thumb_offset,
752 );
753 let next_offset = (target_thumb_offset / metrics.max_thumb_offset) * metrics.max_offset;
754 self.set_scroll_offset(next_offset, metrics.max_offset);
755 }
756
757 fn set_scroll_offset(&self, offset: f32, max_offset: f32) {
758 let clamped_offset = clamp(offset, 0.0, max_offset);
759 if self.inner.orientation == Orientation::Horizontal {
760 self.inner.scroll_state.set_offset_x(clamped_offset);
761 if self.inner.target_handle.get() != HandleValue::Invalid as u64 {
762 ui::set_scroll_offset(
763 self.inner.target_handle.get(),
764 clamped_offset,
765 self.inner.scroll_state.offset_y(),
766 );
767 crate::frame_scheduler::mark_needs_commit();
768 }
769 } else {
770 self.inner.scroll_state.set_offset_y(clamped_offset);
771 if self.inner.target_handle.get() != HandleValue::Invalid as u64 {
772 ui::set_scroll_offset(
773 self.inner.target_handle.get(),
774 self.inner.scroll_state.offset_x(),
775 clamped_offset,
776 );
777 crate::frame_scheduler::mark_needs_commit();
778 }
779 }
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use crate::ffi::{self, Call};
787 use crate::theme::{generate_theme, use_custom_theme, use_system_theme};
788 use crate::Application;
789
790 #[test]
791 fn scrollbar_style_surface_matches_fui_as_vertical_and_horizontal_geometry() {
792 ffi::test::reset();
793 let vertical_state = ScrollState::new();
794 vertical_state.set_viewport_height(100.0);
795 vertical_state.set_content_height(400.0);
796 let vertical = ScrollBar::new(vertical_state, Orientation::Vertical);
797 vertical
798 .track_width(12.0)
799 .thumb_width(6.0)
800 .thumb_min_height(30.0);
801 Application::mount(vertical.render());
802 let vertical_track_handle = vertical.inner.track_node.handle().raw();
803 let vertical_thumb_handle = vertical.inner.thumb_node.handle().raw();
804 let calls = ffi::test::take_calls();
805
806 assert!(calls.iter().any(|call| matches!(
807 call,
808 Call::SetWidth { handle, value, .. } if *handle == vertical_track_handle && (*value - 12.0).abs() < f32::EPSILON
809 )));
810 assert!(calls.iter().any(|call| matches!(
811 call,
812 Call::SetWidth { handle, value, .. } if *handle == vertical_thumb_handle && (*value - 6.0).abs() < f32::EPSILON
813 )));
814 assert!(calls.iter().any(|call| matches!(
815 call,
816 Call::SetHeight { handle, value, .. } if *handle == vertical_thumb_handle && (*value - 30.0).abs() < f32::EPSILON
817 )));
818
819 ffi::test::reset();
820 let horizontal_state = ScrollState::new();
821 horizontal_state.set_viewport_width(100.0);
822 horizontal_state.set_content_width(400.0);
823 let horizontal = ScrollBar::new(horizontal_state, Orientation::Horizontal);
824 horizontal
825 .track_width(14.0)
826 .thumb_width(9.0)
827 .thumb_min_height(32.0);
828 Application::mount(horizontal.render());
829 let horizontal_track_handle = horizontal.inner.track_node.handle().raw();
830 let horizontal_thumb_handle = horizontal.inner.thumb_node.handle().raw();
831 let calls = ffi::test::take_calls();
832
833 assert!(calls.iter().any(|call| matches!(
834 call,
835 Call::SetHeight { handle, value, .. } if *handle == horizontal_track_handle && (*value - 14.0).abs() < f32::EPSILON
836 )));
837 assert!(calls.iter().any(|call| matches!(
838 call,
839 Call::SetHeight { handle, value, .. } if *handle == horizontal_thumb_handle && (*value - 9.0).abs() < f32::EPSILON
840 )));
841 assert!(calls.iter().any(|call| matches!(
842 call,
843 Call::SetWidth { handle, value, .. } if *handle == horizontal_thumb_handle && (*value - 32.0).abs() < f32::EPSILON
844 )));
845 }
846
847 #[test]
848 fn scrollbar_theme_changes_update_default_colors_and_overrides_survive() {
849 ffi::test::reset();
850 use_custom_theme(generate_theme(true, 0x112233FF));
851 let state = ScrollState::new();
852 state.set_viewport_height(100.0);
853 state.set_content_height(400.0);
854 let scrollbar = ScrollBar::new(state, Orientation::Vertical);
855 Application::mount(scrollbar.render());
856 let _ = ffi::test::take_calls();
857
858 use_custom_theme(generate_theme(false, 0x445566FF));
859 let theme = current_theme();
860 let calls = ffi::test::take_calls();
861 assert!(calls.iter().any(|call| matches!(
862 call,
863 Call::SetBgColor { color, .. } if *color == theme.colors.scrollbar_track
864 )));
865 assert!(calls.iter().any(|call| matches!(
866 call,
867 Call::SetBgColor { color, .. } if *color == theme.colors.scrollbar_thumb
868 )));
869
870 ffi::test::reset();
871 let state = ScrollState::new();
872 state.set_viewport_height(100.0);
873 state.set_content_height(400.0);
874 let scrollbar = ScrollBar::new(state, Orientation::Vertical);
875 scrollbar
876 .track_width(10.0)
877 .thumb_width(7.0)
878 .track_corner_radius(5.0)
879 .thumb_corner_radius(4.0)
880 .track_color(0x123456FF)
881 .thumb_color(0xABCDEF88);
882 Application::mount(scrollbar.render());
883 let mount_calls = ffi::test::take_calls();
884 assert!(mount_calls.iter().any(|call| matches!(
885 call,
886 Call::SetBoxStyle { radius_tl, radius_tr, radius_br, radius_bl, .. }
887 if *radius_tl == 5.0
888 && *radius_tr == 5.0
889 && *radius_br == 5.0
890 && *radius_bl == 5.0
891 )));
892 assert!(mount_calls.iter().any(|call| matches!(
893 call,
894 Call::SetBoxStyle { radius_tl, radius_tr, radius_br, radius_bl, .. }
895 if *radius_tl == 4.0
896 && *radius_tr == 4.0
897 && *radius_br == 4.0
898 && *radius_bl == 4.0
899 )));
900
901 use_custom_theme(generate_theme(true, 0x556677FF));
902 let calls = ffi::test::take_calls();
903 assert!(calls.iter().any(|call| matches!(
904 call,
905 Call::SetBgColor { color, .. } if *color == 0x123456FF
906 )));
907 assert!(calls.iter().any(|call| matches!(
908 call,
909 Call::SetBgColor { color, .. } if *color == 0xABCDEF88
910 )));
911
912 use_system_theme();
913 }
914
915 #[test]
916 fn scrollbar_invalid_recipe_values_warn_and_clamp_like_fui_as() {
917 ffi::test::reset();
918 let state = ScrollState::new();
919 state.set_viewport_height(100.0);
920 state.set_content_height(400.0);
921 let scrollbar = ScrollBar::new(state, Orientation::Vertical);
922 scrollbar
923 .track_width(0.0)
924 .thumb_width(-2.0)
925 .thumb_min_height(0.0);
926 Application::mount(scrollbar.render());
927 let calls = ffi::test::take_calls();
928
929 assert!(calls.iter().any(|call| matches!(
930 call,
931 Call::Log { category, message }
932 if category == "Warning/Layout"
933 && message == "ScrollBar.trackThickness() received 0; clamping to 1.0."
934 )));
935 assert!(calls.iter().any(|call| matches!(
936 call,
937 Call::Log { category, message }
938 if category == "Warning/Layout"
939 && message == "ScrollBar.thumbThickness() received -2; clamping to 1.0."
940 )));
941 assert!(calls.iter().any(|call| matches!(
942 call,
943 Call::Log { category, message }
944 if category == "Warning/Layout"
945 && message == "ScrollBar.thumbMinHeight() received 0; clamping to 1.0."
946 )));
947 assert!(calls.iter().any(|call| matches!(
948 call,
949 Call::SetWidth { value, .. } if (*value - 1.0).abs() < f32::EPSILON
950 )));
951 }
952}