Skip to main content

fui/node/
scroll_bar.rs

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)]
154/// Scroll chrome helper matching FUI-AS `ScrollBar`.
155///
156/// This is not a retained `Node`; attach [`render`](Self::render) to a retained
157/// tree. It follows the active theme automatically. Per-instance theme
158/// callbacks belong on the rendered `FlexBox`, avoiding a root/state ownership
159/// cycle in this helper.
160pub 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}