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 let ratio = self.viewport_ratio.clone();
477 handlers = handlers.on_key(move |event, _ctx| {
478 let max = max_scroll.get();
479 if max <= 0.0 {
480 return EventResponse::Ignored;
481 }
482 match event {
483 WidgetEvent::KeyDown { key, .. } => {
484 use teksilo_core::event::Key;
485 let step = step_size;
486 match (orientation, key) {
487 (ScrollBarOrientation::Vertical, Key::ArrowUp) => {
488 set_scroll(scroll_position.get() - step);
489 EventResponse::Handled
490 }
491 (ScrollBarOrientation::Vertical, Key::ArrowDown) => {
492 set_scroll(scroll_position.get() + step);
493 EventResponse::Handled
494 }
495 (ScrollBarOrientation::Horizontal, Key::ArrowLeft) => {
496 set_scroll(scroll_position.get() - step);
497 EventResponse::Handled
498 }
499 (ScrollBarOrientation::Horizontal, Key::ArrowRight) => {
500 set_scroll(scroll_position.get() + step);
501 EventResponse::Handled
502 }
503 (_, Key::Home) => {
504 set_scroll(0.0);
505 EventResponse::Handled
506 }
507 (_, Key::End) => {
508 set_scroll(max);
509 EventResponse::Handled
510 }
511 (_, Key::PageUp) => {
519 set_scroll(scroll_position.get() - page_step(&ratio, max, step));
520 EventResponse::Handled
521 }
522 (_, Key::PageDown) => {
523 set_scroll(scroll_position.get() + page_step(&ratio, max, step));
524 EventResponse::Handled
525 }
526 _ => EventResponse::Ignored,
527 }
528 }
529 _ => EventResponse::Ignored,
530 }
531 });
532 }
533
534 {
536 handlers = handlers.on_access_action(move |action, _ctx| {
537 if action == teksilo_core::accesskit::Action::SetValue {
538 EventResponse::Handled
539 } else {
540 EventResponse::Ignored
541 }
542 });
543 }
544
545 ctx.apply_self_handlers(handlers);
546
547 vec![body_id]
548 }
549
550 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
551 match self.orientation {
552 ScrollBarOrientation::Vertical => {
553 Size::new(self.thickness, proposal.height.unwrap_or(100.0))
554 }
555 ScrollBarOrientation::Horizontal => {
556 Size::new(proposal.width.unwrap_or(100.0), self.thickness)
557 }
558 }
559 .into()
560 }
561
562 fn place_children(
563 &self,
564 bounds: Rect,
565 _proposal: SizeProposal,
566 children: &mut [WidgetPlacement],
567 _ctx: &LayoutContext,
568 ) {
569 self.cached_bounds.set(bounds);
572 for child in children.iter_mut() {
573 child.origin = bounds.origin();
574 child.size = bounds.size();
575 }
576 }
577
578 fn children(&self) -> Vec<WidgetId> {
579 self.body_id.into_iter().collect()
580 }
581
582 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
583 builder.set_hidden();
587 }
588}
589
590fn page_step(ratio: &teksilo_core::signal::Signal<f32>, max: f32, step: f32) -> f32 {
597 let r = ratio.get().clamp(0.0, 1.0);
598 if r <= 0.0 || r >= 1.0 {
599 return step;
600 }
601 let viewport = max * r / (1.0 - r);
602 if viewport.is_finite() && viewport > step {
603 viewport
604 } else {
605 step
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use teksilo_canvas::SizeProposal;
613 use teksilo_core::widget_tree::WidgetTree;
614
615 #[test]
619 fn thumb_color_override_threads_into_style_config() {
620 use std::cell::Cell;
621 use std::rc::Rc;
622 use teksilo_core::build_context::BuildContext;
623 use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
624
625 struct RecordingStyle(Rc<Cell<bool>>);
626 impl ScrollBarStyle for RecordingStyle {
627 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
628 self.0.set(cfg.thumb_color.is_some());
629 ctx.add(crate::primitives::Spacer::new())
630 }
631 }
632
633 let saw_override = Rc::new(Cell::new(false));
634 let mut tree = WidgetTree::new();
635 let bar = ScrollBar::new(
636 ScrollBarOrientation::Vertical,
637 Signal::new(0.0),
638 Signal::new(500.0),
639 Signal::new(0.5),
640 )
641 .style(RecordingStyle(saw_override.clone()))
642 .thumb_color(teksilo_tokens::TextRole::TooltipText);
643 tree.add(bar);
644 tree.layout(SizeProposal::exact(20.0, 200.0));
645 assert!(
646 saw_override.get(),
647 "ScrollBar::thumb_color must thread into ScrollBarStyleConfig::thumb_color"
648 );
649 }
650
651 fn make_scrollbar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
652 let position = Signal::new(0.0_f32);
653 let max_scroll = Signal::new(500.0_f32);
654 let viewport_ratio = Signal::new(0.5_f32); let bar = ScrollBar::new(
657 ScrollBarOrientation::Vertical,
658 position.clone(),
659 max_scroll.clone(),
660 viewport_ratio.clone(),
661 );
662 (bar, position, max_scroll, viewport_ratio)
663 }
664
665 #[test]
666 fn vertical_scrollbar_size() {
667 let (bar, ..) = make_scrollbar();
668 let mut tree = WidgetTree::new();
669 let id = tree.add(bar);
670 tree.layout(SizeProposal {
671 width: None,
672 height: Some(400.0),
673 });
674
675 let bounds = tree.bounds(id);
676 assert!((bounds.width - 8.0).abs() < 0.01);
678 assert!((bounds.height - 400.0).abs() < 0.01);
679 }
680
681 #[test]
682 fn horizontal_scrollbar_size() {
683 let position = Signal::new(0.0_f32);
684 let max_scroll = Signal::new(500.0_f32);
685 let viewport_ratio = Signal::new(0.5_f32);
686
687 let bar = ScrollBar::new(
688 ScrollBarOrientation::Horizontal,
689 position,
690 max_scroll,
691 viewport_ratio,
692 );
693 let mut tree = WidgetTree::new();
694 let id = tree.add(bar);
695 tree.layout(SizeProposal {
696 width: Some(400.0),
697 height: None,
698 });
699
700 let bounds = tree.bounds(id);
701 assert!((bounds.width - 400.0).abs() < 0.01);
703 assert!((bounds.height - 8.0).abs() < 0.01);
704 }
705
706 #[test]
707 fn scrollbar_thumb_drag_updates_position() {
708 let (bar, position, _max, _ratio) = make_scrollbar();
709 let mut tree = WidgetTree::new();
710 let _id = tree.add(bar);
711 tree.layout(SizeProposal::exact(12.0, 400.0));
712
713 tree.render();
715
716 assert!((position.get() - 0.0).abs() < 0.01);
718
719 tree.pointer_move(Point::new(6.0, 10.0));
721 tree.dispatch_event(WidgetEvent::PointerDown {
722 position: Point::new(6.0, 10.0),
723 button: PointerButton::Primary,
724 modifiers: teksilo_core::event::Modifiers::NONE,
725 });
726
727 tree.dispatch_event(WidgetEvent::PointerMove {
734 position: Point::new(6.0, 20.0),
735 });
736 tree.dispatch_event(WidgetEvent::PointerMove {
737 position: Point::new(6.0, 110.0),
738 });
739
740 let pos = position.get();
741 assert!(pos > 200.0, "Expected scroll > 200, got {}", pos);
742 assert!(pos < 300.0, "Expected scroll < 300, got {}", pos);
743 }
744
745 #[test]
746 fn scrollbar_clamps_to_range() {
747 let (bar, position, max_scroll, ..) = make_scrollbar();
748 let mut tree = WidgetTree::new();
749 tree.add(bar);
750 tree.layout(SizeProposal::exact(12.0, 400.0));
751 tree.render();
752
753 for _ in 0..5 {
757 tree.pointer_move(Point::new(6.0, 390.0));
758 tree.dispatch_event(WidgetEvent::PointerDown {
759 position: Point::new(6.0, 390.0),
760 button: PointerButton::Primary,
761 modifiers: teksilo_core::event::Modifiers::NONE,
762 });
763 tree.dispatch_event(WidgetEvent::PointerUp {
765 position: Point::new(6.0, 390.0),
766 button: PointerButton::Primary,
767 modifiers: teksilo_core::event::Modifiers::NONE,
768 });
769 }
770
771 let pos = position.get();
772 let max = max_scroll.get();
773 assert!(
774 (pos - max).abs() < 0.01,
775 "Expected pos to be clamped at max={}, got {}",
776 max,
777 pos,
778 );
779 }
780
781 #[test]
782 fn scrollbar_nothing_to_scroll() {
783 let position = Signal::new(0.0_f32);
784 let max_scroll = Signal::new(0.0_f32); let viewport_ratio = Signal::new(1.0_f32);
786
787 let bar = ScrollBar::new(
788 ScrollBarOrientation::Vertical,
789 position,
790 max_scroll,
791 viewport_ratio,
792 );
793 let mut tree = WidgetTree::new();
794 tree.add(bar);
795 tree.layout(SizeProposal::exact(12.0, 400.0));
796
797 let frame = tree.render();
798 assert!(
801 frame.shapes.is_empty(),
802 "Expected no rendering when nothing to scroll"
803 );
804 }
805
806 #[test]
807 fn scrollbar_is_hidden_from_at() {
808 let (bar, position, _max_scroll, _ratio) = make_scrollbar();
811 position.set(100.0);
812
813 let mut tree = WidgetTree::new();
814 let id = tree.add(bar);
815 tree.layout(SizeProposal::exact(12.0, 400.0));
816
817 let info = tree.accessibility_node(id);
818 assert!(info.is_hidden(), "ScrollBar must be hidden from AT");
819 }
820
821 #[test]
822 fn track_click_pages_forward() {
823 let (bar, position, ..) = make_scrollbar();
824 let mut tree = WidgetTree::new();
825 let _id = tree.add(bar);
826 tree.layout(SizeProposal::exact(12.0, 400.0));
827 tree.render();
828
829 tree.pointer_move(Point::new(6.0, 350.0));
834 tree.dispatch_event(WidgetEvent::PointerDown {
835 position: Point::new(6.0, 350.0),
836 button: PointerButton::Primary,
837 modifiers: teksilo_core::event::Modifiers::NONE,
838 });
839 tree.dispatch_event(WidgetEvent::PointerUp {
840 position: Point::new(6.0, 350.0),
841 button: PointerButton::Primary,
842 modifiers: teksilo_core::event::Modifiers::NONE,
843 });
844
845 let pos = position.get();
846 assert!(
847 pos > 0.0,
848 "Expected positive scroll after track click, got {}",
849 pos
850 );
851 }
852
853 #[test]
854 fn scrollbar_drag_inside_scroll_area_updates_position() {
855 use crate::primitives::MinSize;
861 use crate::scroll_area::{ScrollArea, ScrollBarMode};
862 use teksilo_canvas::Point;
863 use teksilo_core::event::{Modifiers, PointerButton};
864
865 let mut tree = WidgetTree::new();
866 let content = MinSize::new(400.0, 800.0);
869 let root = tree.add(
870 ScrollArea::new()
871 .child(content)
872 .scroll_bar_style(ScrollBarMode::Permanent),
873 );
874 tree.layout(SizeProposal::exact(400.0, 400.0));
875 tree.render();
876
877 let sb_id = tree.children(root)[1];
880 let sb_bounds = tree.bounds(sb_id);
881 assert!(
882 sb_bounds.width > 0.0,
883 "scrollbar should have non-zero width"
884 );
885 assert!(
886 sb_bounds.height > 0.0,
887 "scrollbar should have non-zero height"
888 );
889
890 let thumb_cx = sb_bounds.x + sb_bounds.width / 2.0;
892 let thumb_cy = sb_bounds.y + sb_bounds.height / 4.0;
893 tree.pointer_move(Point::new(thumb_cx, thumb_cy));
894 tree.dispatch_event(WidgetEvent::PointerDown {
895 position: Point::new(thumb_cx, thumb_cy),
896 button: PointerButton::Primary,
897 modifiers: Modifiers::NONE,
898 });
899
900 tree.dispatch_event(WidgetEvent::PointerMove {
902 position: Point::new(thumb_cx, thumb_cy + 10.0),
903 });
904 tree.dispatch_event(WidgetEvent::PointerMove {
906 position: Point::new(thumb_cx, thumb_cy + 100.0),
907 });
908 tree.dispatch_event(WidgetEvent::PointerUp {
909 position: Point::new(thumb_cx, thumb_cy + 100.0),
910 button: PointerButton::Primary,
911 modifiers: Modifiers::NONE,
912 });
913
914 tree.layout(SizeProposal::exact(400.0, 400.0));
918
919 let final_scroll = tree.hit_test(Point::new(1.0, 1.0)); let _ = final_scroll;
924 let content_bounds = tree.bounds(tree.children(root)[0]);
928 assert!(
929 content_bounds.y < -1.0,
930 "content should have scrolled up (y < 0); got y={}",
931 content_bounds.y
932 );
933 }
934
935 #[test]
936 fn drag_release_outside_does_not_stick() {
937 let (bar, position, ..) = make_scrollbar();
942 let mut tree = WidgetTree::new();
943 let _id = tree.add(bar);
944 tree.layout(SizeProposal::exact(12.0, 400.0));
945 tree.render();
946
947 tree.pointer_move(Point::new(6.0, 10.0));
949 tree.dispatch_event(WidgetEvent::PointerDown {
950 position: Point::new(6.0, 10.0),
951 button: PointerButton::Primary,
952 modifiers: teksilo_core::event::Modifiers::NONE,
953 });
954
955 tree.dispatch_event(WidgetEvent::PointerMove {
957 position: Point::new(200.0, 300.0),
958 });
959
960 tree.dispatch_event(WidgetEvent::PointerUp {
962 position: Point::new(200.0, 300.0),
963 button: PointerButton::Primary,
964 modifiers: teksilo_core::event::Modifiers::NONE,
965 });
966
967 let pos_before = position.get();
969 tree.pointer_move(Point::new(6.0, 50.0));
970 tree.dispatch_event(WidgetEvent::PointerMove {
971 position: Point::new(6.0, 50.0),
972 });
973
974 let pos_after = position.get();
975 assert!(
976 (pos_after - pos_before).abs() < 0.01,
977 "Hovering after release should not move scroll: before={}, after={}",
978 pos_before,
979 pos_after,
980 );
981 }
982}