1use std::cell::Cell;
40use std::rc::Rc;
41
42use teksilo_canvas::{Point, Rect, Size, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::color_prop::ColorProp;
45use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
46use teksilo_core::gesture::DragPhase;
47use teksilo_core::signal::Signal;
48use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig, SharedScrollBarStyle};
49use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
50use teksilo_core::widget_builder::HandlerSet;
51use teksilo_core::widget_id::WidgetId;
52
53pub use teksilo_core::styles::ScrollBarOrientation;
58pub use teksilo_core::styles::ScrollBarVariant;
59pub use teksilo_core::styles::ScrollBarVariant as ScrollBarVisual;
60
61pub struct ScrollBar {
66 orientation: ScrollBarOrientation,
67 scroll_position: Signal<f32>,
70 max_scroll: Signal<f32>,
73 viewport_ratio: Signal<f32>,
76
77 hovered: Signal<bool>,
80 dragging: Signal<bool>,
82 drag_start_pointer: Rc<Cell<f32>>,
84 drag_start_scroll: Rc<Cell<f32>>,
86 cached_bounds: Rc<Cell<Rect>>,
88 body_id: Option<WidgetId>,
91
92 thickness: f32,
95 min_thumb_length: f32,
97 step_size: f32,
99 variant: ScrollBarVariant,
101 style_override: Option<SharedScrollBarStyle>,
103 thumb_color: Option<ColorProp>,
108}
109
110impl std::fmt::Debug for ScrollBar {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.debug_struct("ScrollBar")
113 .field("orientation", &self.orientation)
114 .field("hovered", &self.hovered.get())
115 .field("dragging", &self.dragging.get())
116 .field("variant", &self.variant)
117 .finish()
118 }
119}
120
121impl ScrollBar {
122 pub fn new(
128 orientation: ScrollBarOrientation,
129 scroll_position: Signal<f32>,
130 max_scroll: Signal<f32>,
131 viewport_ratio: Signal<f32>,
132 ) -> Self {
133 Self {
136 orientation,
137 scroll_position,
138 max_scroll,
139 viewport_ratio,
140 hovered: Signal::new(false),
141 dragging: Signal::new(false),
142 drag_start_pointer: Rc::new(Cell::new(0.0)),
143 drag_start_scroll: Rc::new(Cell::new(0.0)),
144 cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
145 body_id: None,
146 thickness: 8.0,
147 min_thumb_length: 24.0,
148 step_size: 40.0,
149 variant: ScrollBarVariant::default(),
150 style_override: None,
151 thumb_color: None,
152 }
153 }
154
155 pub fn thickness(mut self, thickness: f32) -> Self {
157 self.thickness = thickness;
158 self
159 }
160
161 pub fn min_thumb_length(mut self, len: f32) -> Self {
163 self.min_thumb_length = len;
164 self
165 }
166
167 pub fn step_size(mut self, step: f32) -> Self {
169 self.step_size = step;
170 self
171 }
172
173 pub fn visual(mut self, variant: ScrollBarVariant) -> Self {
177 self.variant = variant;
178 self
179 }
180
181 pub fn variant(mut self, variant: ScrollBarVariant) -> Self {
183 self.variant = variant;
184 self
185 }
186
187 pub fn style(mut self, style: impl ScrollBarStyle) -> Self {
189 self.style_override = Some(Rc::new(style));
190 self
191 }
192
193 pub fn thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
202 self.thumb_color = Some(color.into());
203 self
204 }
205
206 fn track_length(&self) -> f32 {
211 let bounds = self.cached_bounds.get();
212 match self.orientation {
213 ScrollBarOrientation::Vertical => bounds.height,
214 ScrollBarOrientation::Horizontal => bounds.width,
215 }
216 }
217
218 fn thumb_length(&self) -> f32 {
220 let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
221 let track = self.track_length();
222 (track * ratio).max(self.min_thumb_length).min(track)
223 }
224
225 fn thumb_offset(&self) -> f32 {
227 let max = self.max_scroll.get();
228 if max <= 0.0 {
229 return 0.0;
230 }
231 let pos = self.scroll_position.get();
232 let ratio = (pos / max).clamp(0.0, 1.0);
233 let available = self.track_length() - self.thumb_length();
234 ratio * available
235 }
236
237 fn thumb_rect(&self) -> Rect {
239 let bounds = self.cached_bounds.get();
240 let offset = self.thumb_offset();
241 let thumb_len = self.thumb_length();
242 match self.orientation {
243 ScrollBarOrientation::Vertical => {
244 Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
245 }
246 ScrollBarOrientation::Horizontal => {
247 Rect::new(bounds.x + offset, bounds.y, thumb_len, bounds.height)
248 }
249 }
250 }
251}
252
253impl Widget for ScrollBar {
254 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
255 let style: SharedScrollBarStyle = self
258 .style_override
259 .clone()
260 .or_else(|| ctx.theme().style_slots.scroll_bar.clone())
261 .unwrap_or_else(|| Rc::new(crate::styles::RecipeScrollBarStyle::default()));
262
263 let scroll_ratio = self
266 .scroll_position
267 .zip(&self.max_scroll)
268 .map(|(pos, max)| {
269 if *max <= 0.0 {
270 0.0
271 } else {
272 (*pos / *max).clamp(0.0, 1.0)
273 }
274 });
275 let is_idle = self.max_scroll.map(|m| *m <= 0.0);
277
278 let cfg = ScrollBarStyleConfig {
279 scroll_ratio,
280 viewport_ratio: self.viewport_ratio.clone(),
281 is_hovered: self.hovered.clone(),
282 is_dragging: self.dragging.clone(),
283 is_idle,
284 orientation: self.orientation,
285 variant: self.variant,
286 min_thumb_length: self.min_thumb_length,
287 thumb_color: self.thumb_color.clone(),
288 };
289 let body_id = style.make_body(&cfg, ctx);
290 self.body_id = Some(body_id);
291
292 let orientation = self.orientation;
293 let scroll_position = self.scroll_position.clone();
294 let max_scroll = self.max_scroll.clone();
295 let viewport_ratio = self.viewport_ratio.clone();
296 let hovered = self.hovered.clone();
297 let dragging = self.dragging.clone();
298 let drag_start_pointer = self.drag_start_pointer.clone();
299 let drag_start_scroll = self.drag_start_scroll.clone();
300 let cached_bounds = self.cached_bounds.clone();
301 let step_size = self.step_size;
302 let min_thumb_length = self.min_thumb_length;
303
304 let axis_value = move |point: Point| -> f32 {
305 match orientation {
306 ScrollBarOrientation::Vertical => point.y,
307 ScrollBarOrientation::Horizontal => point.x,
308 }
309 };
310
311 let set_scroll = {
312 let scroll_position = scroll_position.clone();
313 let max_scroll = max_scroll.clone();
314 move |value: f32| {
315 let max = max_scroll.get();
316 scroll_position.set(value.clamp(0.0, max));
317 }
318 };
319
320 let track_length = {
321 let cached_bounds = cached_bounds.clone();
322 move || -> f32 {
323 let bounds = cached_bounds.get();
324 match orientation {
325 ScrollBarOrientation::Vertical => bounds.height,
326 ScrollBarOrientation::Horizontal => bounds.width,
327 }
328 }
329 };
330
331 let thumb_length = {
332 let viewport_ratio = viewport_ratio.clone();
333 let track_length = track_length.clone();
334 move || -> f32 {
335 let ratio = viewport_ratio.get().clamp(0.0, 1.0);
336 let track = track_length();
337 (track * ratio).max(min_thumb_length).min(track)
338 }
339 };
340
341 let thumb_rect = {
342 let cached_bounds = cached_bounds.clone();
343 let scroll_position = scroll_position.clone();
344 let max_scroll = max_scroll.clone();
345 let track_length = track_length.clone();
346 let thumb_length = thumb_length.clone();
347 move || -> Rect {
348 let bounds = cached_bounds.get();
349 let max = max_scroll.get();
350 let offset = if max <= 0.0 {
351 0.0
352 } else {
353 let pos = scroll_position.get();
354 let ratio = (pos / max).clamp(0.0, 1.0);
355 let available = track_length() - thumb_length();
356 ratio * available
357 };
358 let tl = thumb_length();
359 match orientation {
363 ScrollBarOrientation::Vertical => Rect::new(0.0, offset, bounds.width, tl),
364 ScrollBarOrientation::Horizontal => Rect::new(offset, 0.0, tl, bounds.height),
365 }
366 }
367 };
368
369 let mut handlers = HandlerSet::new().focusable(false);
373
374 {
384 let dragging = dragging.clone();
385 let drag_start_pointer = drag_start_pointer.clone();
386 let drag_start_scroll = drag_start_scroll.clone();
387 let scroll_position = scroll_position.clone();
388 let max_scroll = max_scroll.clone();
389 let set_scroll = set_scroll.clone();
390 let thumb_rect = thumb_rect.clone();
391 let track_length = track_length.clone();
392 let thumb_length = thumb_length.clone();
393 handlers = handlers.on_drag(move |phase, _ctx| {
394 let max = max_scroll.get();
395 if max <= 0.0 {
396 return;
397 }
398 match phase {
399 DragPhase::Started {
400 position,
401 button: PointerButton::Primary,
402 } if thumb_rect().contains(position) => {
403 dragging.set(true);
404 drag_start_pointer.set(axis_value(position));
405 drag_start_scroll.set(scroll_position.get());
406 }
407 DragPhase::Moved { position, .. } if dragging.get() => {
408 let current = axis_value(position);
409 let delta_pixels = current - drag_start_pointer.get();
410 let available = track_length() - thumb_length();
411 if available > 0.0 {
412 let scroll_delta = delta_pixels * max / available;
413 set_scroll(drag_start_scroll.get() + scroll_delta);
414 }
415 }
416 DragPhase::Ended { .. } => {
417 dragging.set(false);
418 }
419 _ => {}
420 }
421 });
422 }
423
424 {
430 let scroll_position = scroll_position.clone();
431 let max_scroll = max_scroll.clone();
432 let viewport_ratio = viewport_ratio.clone();
433 let set_scroll = set_scroll.clone();
434 let thumb_rect = thumb_rect.clone();
435 handlers = handlers.on_tap(move |event, _ctx| {
436 let max = max_scroll.get();
437 if max <= 0.0 {
438 return;
439 }
440 let tr = thumb_rect();
441 let position = event.position;
442 if tr.contains(position) {
443 return;
444 }
445 let click_axis = axis_value(position);
446 let thumb_center = match orientation {
447 ScrollBarOrientation::Vertical => tr.y + tr.height / 2.0,
448 ScrollBarOrientation::Horizontal => tr.x + tr.width / 2.0,
449 };
450 let ratio = viewport_ratio.get().clamp(0.001, 0.999);
451 let viewport_scroll = max * ratio / (1.0 - ratio);
452 let current = scroll_position.get();
453 if click_axis < thumb_center {
454 set_scroll(current - viewport_scroll);
455 } else {
456 set_scroll(current + viewport_scroll);
457 }
458 });
459 }
460
461 {
465 let hovered = hovered.clone();
466 handlers = handlers.on_hover(move |entered, _ctx| {
467 hovered.set(entered);
468 });
469 }
470
471 {
473 let scroll_position = scroll_position.clone();
474 let max_scroll = max_scroll.clone();
475 let set_scroll = set_scroll.clone();
476 handlers = handlers.on_key(move |event, _ctx| {
477 let max = max_scroll.get();
478 if max <= 0.0 {
479 return EventResponse::Ignored;
480 }
481 match event {
482 WidgetEvent::KeyDown { key, .. } => {
483 use teksilo_core::event::Key;
484 let step = step_size;
485 match (orientation, key) {
486 (ScrollBarOrientation::Vertical, Key::ArrowUp) => {
487 set_scroll(scroll_position.get() - step);
488 EventResponse::Handled
489 }
490 (ScrollBarOrientation::Vertical, Key::ArrowDown) => {
491 set_scroll(scroll_position.get() + step);
492 EventResponse::Handled
493 }
494 (ScrollBarOrientation::Horizontal, Key::ArrowLeft) => {
495 set_scroll(scroll_position.get() - step);
496 EventResponse::Handled
497 }
498 (ScrollBarOrientation::Horizontal, Key::ArrowRight) => {
499 set_scroll(scroll_position.get() + step);
500 EventResponse::Handled
501 }
502 (_, Key::Home) => {
503 set_scroll(0.0);
504 EventResponse::Handled
505 }
506 (_, Key::End) => {
507 set_scroll(max);
508 EventResponse::Handled
509 }
510 _ => EventResponse::Ignored,
511 }
512 }
513 _ => EventResponse::Ignored,
514 }
515 });
516 }
517
518 {
520 handlers = handlers.on_access_action(move |action, _ctx| {
521 if action == teksilo_core::accesskit::Action::SetValue {
522 EventResponse::Handled
523 } else {
524 EventResponse::Ignored
525 }
526 });
527 }
528
529 ctx.apply_self_handlers(handlers);
530
531 vec![body_id]
532 }
533
534 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
535 match self.orientation {
536 ScrollBarOrientation::Vertical => {
537 Size::new(self.thickness, proposal.height.unwrap_or(100.0))
538 }
539 ScrollBarOrientation::Horizontal => {
540 Size::new(proposal.width.unwrap_or(100.0), self.thickness)
541 }
542 }
543 .into()
544 }
545
546 fn place_children(
547 &self,
548 bounds: Rect,
549 _proposal: SizeProposal,
550 children: &mut [WidgetPlacement],
551 _ctx: &LayoutContext,
552 ) {
553 self.cached_bounds.set(bounds);
556 for child in children.iter_mut() {
557 child.origin = bounds.origin();
558 child.size = bounds.size();
559 }
560 }
561
562 fn children(&self) -> Vec<WidgetId> {
563 self.body_id.into_iter().collect()
564 }
565
566 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
567 builder.set_hidden();
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use teksilo_canvas::SizeProposal;
578 use teksilo_core::widget_tree::WidgetTree;
579
580 #[test]
584 fn thumb_color_override_threads_into_style_config() {
585 use std::cell::Cell;
586 use std::rc::Rc;
587 use teksilo_core::build_context::BuildContext;
588 use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
589
590 struct RecordingStyle(Rc<Cell<bool>>);
591 impl ScrollBarStyle for RecordingStyle {
592 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
593 self.0.set(cfg.thumb_color.is_some());
594 ctx.add(crate::primitives::Spacer::new())
595 }
596 }
597
598 let saw_override = Rc::new(Cell::new(false));
599 let mut tree = WidgetTree::new();
600 let bar = ScrollBar::new(
601 ScrollBarOrientation::Vertical,
602 Signal::new(0.0),
603 Signal::new(500.0),
604 Signal::new(0.5),
605 )
606 .style(RecordingStyle(saw_override.clone()))
607 .thumb_color(teksilo_tokens::TextRole::TooltipText);
608 tree.add(bar);
609 tree.layout(SizeProposal::exact(20.0, 200.0));
610 assert!(
611 saw_override.get(),
612 "ScrollBar::thumb_color must thread into ScrollBarStyleConfig::thumb_color"
613 );
614 }
615
616 fn make_scrollbar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
617 let position = Signal::new(0.0_f32);
618 let max_scroll = Signal::new(500.0_f32);
619 let viewport_ratio = Signal::new(0.5_f32); let bar = ScrollBar::new(
622 ScrollBarOrientation::Vertical,
623 position.clone(),
624 max_scroll.clone(),
625 viewport_ratio.clone(),
626 );
627 (bar, position, max_scroll, viewport_ratio)
628 }
629
630 #[test]
631 fn vertical_scrollbar_size() {
632 let (bar, ..) = make_scrollbar();
633 let mut tree = WidgetTree::new();
634 let id = tree.add(bar);
635 tree.layout(SizeProposal {
636 width: None,
637 height: Some(400.0),
638 });
639
640 let bounds = tree.bounds(id);
641 assert!((bounds.width - 8.0).abs() < 0.01);
643 assert!((bounds.height - 400.0).abs() < 0.01);
644 }
645
646 #[test]
647 fn horizontal_scrollbar_size() {
648 let position = Signal::new(0.0_f32);
649 let max_scroll = Signal::new(500.0_f32);
650 let viewport_ratio = Signal::new(0.5_f32);
651
652 let bar = ScrollBar::new(
653 ScrollBarOrientation::Horizontal,
654 position,
655 max_scroll,
656 viewport_ratio,
657 );
658 let mut tree = WidgetTree::new();
659 let id = tree.add(bar);
660 tree.layout(SizeProposal {
661 width: Some(400.0),
662 height: None,
663 });
664
665 let bounds = tree.bounds(id);
666 assert!((bounds.width - 400.0).abs() < 0.01);
668 assert!((bounds.height - 8.0).abs() < 0.01);
669 }
670
671 #[test]
672 fn scrollbar_thumb_drag_updates_position() {
673 let (bar, position, _max, _ratio) = make_scrollbar();
674 let mut tree = WidgetTree::new();
675 let _id = tree.add(bar);
676 tree.layout(SizeProposal::exact(12.0, 400.0));
677
678 tree.render();
680
681 assert!((position.get() - 0.0).abs() < 0.01);
683
684 tree.pointer_move(Point::new(6.0, 10.0));
686 tree.dispatch_event(WidgetEvent::PointerDown {
687 position: Point::new(6.0, 10.0),
688 button: PointerButton::Primary,
689 modifiers: teksilo_core::event::Modifiers::NONE,
690 });
691
692 tree.dispatch_event(WidgetEvent::PointerMove {
699 position: Point::new(6.0, 20.0),
700 });
701 tree.dispatch_event(WidgetEvent::PointerMove {
702 position: Point::new(6.0, 110.0),
703 });
704
705 let pos = position.get();
706 assert!(pos > 200.0, "Expected scroll > 200, got {}", pos);
707 assert!(pos < 300.0, "Expected scroll < 300, got {}", pos);
708 }
709
710 #[test]
711 fn scrollbar_clamps_to_range() {
712 let (bar, position, max_scroll, ..) = make_scrollbar();
713 let mut tree = WidgetTree::new();
714 tree.add(bar);
715 tree.layout(SizeProposal::exact(12.0, 400.0));
716 tree.render();
717
718 for _ in 0..5 {
722 tree.pointer_move(Point::new(6.0, 390.0));
723 tree.dispatch_event(WidgetEvent::PointerDown {
724 position: Point::new(6.0, 390.0),
725 button: PointerButton::Primary,
726 modifiers: teksilo_core::event::Modifiers::NONE,
727 });
728 tree.dispatch_event(WidgetEvent::PointerUp {
730 position: Point::new(6.0, 390.0),
731 button: PointerButton::Primary,
732 modifiers: teksilo_core::event::Modifiers::NONE,
733 });
734 }
735
736 let pos = position.get();
737 let max = max_scroll.get();
738 assert!(
739 (pos - max).abs() < 0.01,
740 "Expected pos to be clamped at max={}, got {}",
741 max,
742 pos,
743 );
744 }
745
746 #[test]
747 fn scrollbar_nothing_to_scroll() {
748 let position = Signal::new(0.0_f32);
749 let max_scroll = Signal::new(0.0_f32); let viewport_ratio = Signal::new(1.0_f32);
751
752 let bar = ScrollBar::new(
753 ScrollBarOrientation::Vertical,
754 position,
755 max_scroll,
756 viewport_ratio,
757 );
758 let mut tree = WidgetTree::new();
759 tree.add(bar);
760 tree.layout(SizeProposal::exact(12.0, 400.0));
761
762 let frame = tree.render();
763 assert!(
766 frame.shapes.is_empty(),
767 "Expected no rendering when nothing to scroll"
768 );
769 }
770
771 #[test]
772 fn scrollbar_is_hidden_from_at() {
773 let (bar, position, _max_scroll, _ratio) = make_scrollbar();
776 position.set(100.0);
777
778 let mut tree = WidgetTree::new();
779 let id = tree.add(bar);
780 tree.layout(SizeProposal::exact(12.0, 400.0));
781
782 let info = tree.accessibility_node(id);
783 assert!(info.is_hidden(), "ScrollBar must be hidden from AT");
784 }
785
786 #[test]
787 fn track_click_pages_forward() {
788 let (bar, position, ..) = make_scrollbar();
789 let mut tree = WidgetTree::new();
790 let _id = tree.add(bar);
791 tree.layout(SizeProposal::exact(12.0, 400.0));
792 tree.render();
793
794 tree.pointer_move(Point::new(6.0, 350.0));
799 tree.dispatch_event(WidgetEvent::PointerDown {
800 position: Point::new(6.0, 350.0),
801 button: PointerButton::Primary,
802 modifiers: teksilo_core::event::Modifiers::NONE,
803 });
804 tree.dispatch_event(WidgetEvent::PointerUp {
805 position: Point::new(6.0, 350.0),
806 button: PointerButton::Primary,
807 modifiers: teksilo_core::event::Modifiers::NONE,
808 });
809
810 let pos = position.get();
811 assert!(
812 pos > 0.0,
813 "Expected positive scroll after track click, got {}",
814 pos
815 );
816 }
817
818 #[test]
819 fn scrollbar_drag_inside_scroll_area_updates_position() {
820 use crate::primitives::MinSize;
826 use crate::scroll_area::{ScrollArea, ScrollBarMode};
827 use teksilo_canvas::Point;
828 use teksilo_core::event::{Modifiers, PointerButton};
829
830 let mut tree = WidgetTree::new();
831 let content = MinSize::new(400.0, 800.0);
834 let root = tree.add(
835 ScrollArea::new()
836 .child(content)
837 .scroll_bar_style(ScrollBarMode::Permanent),
838 );
839 tree.layout(SizeProposal::exact(400.0, 400.0));
840 tree.render();
841
842 let sb_id = tree.children(root)[1];
845 let sb_bounds = tree.bounds(sb_id);
846 assert!(
847 sb_bounds.width > 0.0,
848 "scrollbar should have non-zero width"
849 );
850 assert!(
851 sb_bounds.height > 0.0,
852 "scrollbar should have non-zero height"
853 );
854
855 let thumb_cx = sb_bounds.x + sb_bounds.width / 2.0;
857 let thumb_cy = sb_bounds.y + sb_bounds.height / 4.0;
858 tree.pointer_move(Point::new(thumb_cx, thumb_cy));
859 tree.dispatch_event(WidgetEvent::PointerDown {
860 position: Point::new(thumb_cx, thumb_cy),
861 button: PointerButton::Primary,
862 modifiers: Modifiers::NONE,
863 });
864
865 tree.dispatch_event(WidgetEvent::PointerMove {
867 position: Point::new(thumb_cx, thumb_cy + 10.0),
868 });
869 tree.dispatch_event(WidgetEvent::PointerMove {
871 position: Point::new(thumb_cx, thumb_cy + 100.0),
872 });
873 tree.dispatch_event(WidgetEvent::PointerUp {
874 position: Point::new(thumb_cx, thumb_cy + 100.0),
875 button: PointerButton::Primary,
876 modifiers: Modifiers::NONE,
877 });
878
879 tree.layout(SizeProposal::exact(400.0, 400.0));
883
884 let final_scroll = tree.hit_test(Point::new(1.0, 1.0)); let _ = final_scroll;
889 let content_bounds = tree.bounds(tree.children(root)[0]);
893 assert!(
894 content_bounds.y < -1.0,
895 "content should have scrolled up (y < 0); got y={}",
896 content_bounds.y
897 );
898 }
899
900 #[test]
901 fn drag_release_outside_does_not_stick() {
902 let (bar, position, ..) = make_scrollbar();
907 let mut tree = WidgetTree::new();
908 let _id = tree.add(bar);
909 tree.layout(SizeProposal::exact(12.0, 400.0));
910 tree.render();
911
912 tree.pointer_move(Point::new(6.0, 10.0));
914 tree.dispatch_event(WidgetEvent::PointerDown {
915 position: Point::new(6.0, 10.0),
916 button: PointerButton::Primary,
917 modifiers: teksilo_core::event::Modifiers::NONE,
918 });
919
920 tree.dispatch_event(WidgetEvent::PointerMove {
922 position: Point::new(200.0, 300.0),
923 });
924
925 tree.dispatch_event(WidgetEvent::PointerUp {
927 position: Point::new(200.0, 300.0),
928 button: PointerButton::Primary,
929 modifiers: teksilo_core::event::Modifiers::NONE,
930 });
931
932 let pos_before = position.get();
934 tree.pointer_move(Point::new(6.0, 50.0));
935 tree.dispatch_event(WidgetEvent::PointerMove {
936 position: Point::new(6.0, 50.0),
937 });
938
939 let pos_after = position.get();
940 assert!(
941 (pos_after - pos_before).abs() < 0.01,
942 "Hovering after release should not move scroll: before={}, after={}",
943 pos_before,
944 pos_after,
945 );
946 }
947}