1use std::cell::Cell;
59use std::rc::Rc;
60
61use teksilo_canvas::{EdgeInsets, Point, Rect, Size, SizeProposal};
62use teksilo_core::accessibility::AccessNodeBuilder;
63use teksilo_core::color_prop::ColorProp;
64use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
65use teksilo_core::gesture::DragPhase;
66use teksilo_core::partition::TargetRegion;
67use teksilo_core::signal::Signal;
68use teksilo_core::styles::density::dp;
69use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig, SharedScrollBarStyle};
70use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
71use teksilo_core::widget_builder::HandlerSet;
72use teksilo_core::widget_id::WidgetId;
73use teksilo_tokens::{RevealPolicy, TargetRole};
74
75use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
76
77pub use teksilo_core::styles::ScrollBarOrientation;
82pub use teksilo_core::styles::ScrollBarVariant;
83pub use teksilo_core::styles::ScrollBarVariant as ScrollBarVisual;
84
85pub const SCROLLBAR_COARSE_TARGET: f32 = 48.0;
91
92pub const SCROLLBAR_MIN_THUMB_LENGTH: f32 = 24.0;
96
97pub const SCROLLBAR_PART_THUMB: u16 = 0;
102pub const SCROLLBAR_PART_TRACK: u16 = 1;
104
105pub struct ScrollBar {
110 orientation: ScrollBarOrientation,
111 scroll_position: Signal<f32>,
114 max_scroll: Signal<f32>,
117 viewport_ratio: Signal<f32>,
120
121 hovered: Signal<bool>,
124 dragging: Signal<bool>,
126 drag_start_pointer: Rc<Cell<f32>>,
128 drag_start_scroll: Rc<Cell<f32>>,
130 cached_bounds: Rc<Cell<Rect>>,
132 cached_rtl: Rc<Cell<bool>>,
136 body_id: Option<WidgetId>,
139
140 thickness: f32,
143 min_thumb_length: Option<f32>,
145 resolved_min_thumb_length: Rc<Cell<f32>>,
151 revealed: Signal<bool>,
155 step_size: f32,
157 variant: ScrollBarVariant,
159 style_override: Option<SharedScrollBarStyle>,
161 thumb_color: Option<ColorProp>,
166}
167
168impl std::fmt::Debug for ScrollBar {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 f.debug_struct("ScrollBar")
171 .field("orientation", &self.orientation)
172 .field("hovered", &self.hovered.get())
173 .field("dragging", &self.dragging.get())
174 .field("variant", &self.variant)
175 .finish()
176 }
177}
178
179impl ScrollBar {
180 pub fn new(
186 orientation: ScrollBarOrientation,
187 scroll_position: Signal<f32>,
188 max_scroll: Signal<f32>,
189 viewport_ratio: Signal<f32>,
190 ) -> Self {
191 Self {
194 orientation,
195 scroll_position,
196 max_scroll,
197 viewport_ratio,
198 hovered: Signal::new(false),
199 dragging: Signal::new(false),
200 drag_start_pointer: Rc::new(Cell::new(0.0)),
201 drag_start_scroll: Rc::new(Cell::new(0.0)),
202 cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
203 cached_rtl: Rc::new(Cell::new(false)),
204 body_id: None,
205 thickness: 8.0,
206 min_thumb_length: None,
207 resolved_min_thumb_length: Rc::new(Cell::new(SCROLLBAR_MIN_THUMB_LENGTH)),
208 revealed: Signal::new(false),
209 step_size: 40.0,
210 variant: ScrollBarVariant::default(),
211 style_override: None,
212 thumb_color: None,
213 }
214 }
215
216 pub fn thickness(mut self, thickness: f32) -> Self {
218 self.thickness = thickness;
219 self
220 }
221
222 pub fn min_thumb_length(mut self, len: f32) -> Self {
228 self.min_thumb_length = Some(len);
229 self
230 }
231
232 pub fn reveal(mut self, revealed: Signal<bool>) -> Self {
240 self.revealed = revealed;
241 self
242 }
243
244 pub fn step_size(mut self, step: f32) -> Self {
246 self.step_size = step;
247 self
248 }
249
250 pub fn visual(mut self, variant: ScrollBarVariant) -> Self {
254 self.variant = variant;
255 self
256 }
257
258 pub fn variant(mut self, variant: ScrollBarVariant) -> Self {
260 self.variant = variant;
261 self
262 }
263
264 pub fn style(mut self, style: impl ScrollBarStyle) -> Self {
266 self.style_override = Some(Rc::new(style));
267 self
268 }
269
270 pub fn thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
279 self.thumb_color = Some(color.into());
280 self
281 }
282
283 fn track_length(&self) -> f32 {
288 let bounds = self.cached_bounds.get();
289 match self.orientation {
290 ScrollBarOrientation::Vertical => bounds.height,
291 ScrollBarOrientation::Horizontal => bounds.width,
292 }
293 }
294
295 fn min_thumb(&self) -> f32 {
298 self.min_thumb_length
299 .unwrap_or_else(|| self.resolved_min_thumb_length.get())
300 }
301
302 fn thumb_length(&self) -> f32 {
304 let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
305 let track = self.track_length();
306 (track * ratio).max(self.min_thumb()).min(track)
307 }
308
309 fn thumb_offset(&self) -> f32 {
311 let max = self.max_scroll.get();
312 if max <= 0.0 {
313 return 0.0;
314 }
315 let pos = self.scroll_position.get();
316 let ratio = (pos / max).clamp(0.0, 1.0);
317 let available = self.track_length() - self.thumb_length();
318 ratio * available
319 }
320
321 fn thumb_rect(&self) -> Rect {
324 let bounds = self.cached_bounds.get();
325 let offset = self.thumb_offset();
326 let thumb_len = self.thumb_length();
327 match self.orientation {
328 ScrollBarOrientation::Vertical => {
329 Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
330 }
331 ScrollBarOrientation::Horizontal => {
332 Rect::new(bounds.x + offset, bounds.y, thumb_len, bounds.height)
333 }
334 }
335 }
336}
337
338impl Widget for ScrollBar {
339 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
340 let style: SharedScrollBarStyle = self
343 .style_override
344 .clone()
345 .or_else(|| ctx.theme().style_slots.scroll_bar.clone())
346 .unwrap_or_else(|| {
347 Rc::new(crate::styles::RecipeScrollBarStyle::for_tokens(
348 &ctx.theme().input,
349 ))
350 });
351
352 let min_thumb_length = self.min_thumb_length.unwrap_or_else(|| {
358 dp(
359 SCROLLBAR_MIN_THUMB_LENGTH,
360 TargetRole::Target,
361 &ctx.theme().input,
362 )
363 });
364 self.resolved_min_thumb_length.set(min_thumb_length);
365
366 if ctx.theme().input.reveal == RevealPolicy::Always && !self.revealed.get() {
369 self.revealed.set(true);
370 }
371
372 let scroll_ratio = self
375 .scroll_position
376 .zip(&self.max_scroll)
377 .map(|(pos, max)| {
378 if *max <= 0.0 {
379 0.0
380 } else {
381 (*pos / *max).clamp(0.0, 1.0)
382 }
383 });
384 let is_idle = self.max_scroll.map(|m| *m <= 0.0);
386
387 let cfg = ScrollBarStyleConfig {
388 scroll_ratio,
389 viewport_ratio: self.viewport_ratio.clone(),
390 is_hovered: self.hovered.or(&self.revealed),
394 is_dragging: self.dragging.clone(),
395 is_idle,
396 orientation: self.orientation,
397 variant: self.variant,
398 min_thumb_length,
399 thumb_color: self.thumb_color.clone(),
400 };
401 let body_id = style.make_body(&cfg, ctx);
402 self.body_id = Some(body_id);
403
404 let orientation = self.orientation;
405 let scroll_position = self.scroll_position.clone();
406 let max_scroll = self.max_scroll.clone();
407 let viewport_ratio = self.viewport_ratio.clone();
408 let hovered = self.hovered.clone();
409 let dragging = self.dragging.clone();
410 let drag_start_pointer = self.drag_start_pointer.clone();
411 let drag_start_scroll = self.drag_start_scroll.clone();
412 let cached_bounds = self.cached_bounds.clone();
413 let step_size = self.step_size;
414
415 let mirrored = {
421 let cached_rtl = self.cached_rtl.clone();
422 move || -> bool {
423 matches!(orientation, ScrollBarOrientation::Horizontal) && cached_rtl.get()
424 }
425 };
426
427 let axis_value = move |point: Point| -> f32 {
428 match orientation {
429 ScrollBarOrientation::Vertical => point.y,
430 ScrollBarOrientation::Horizontal => point.x,
431 }
432 };
433
434 let set_scroll = {
435 let scroll_position = scroll_position.clone();
436 let max_scroll = max_scroll.clone();
437 move |value: f32| {
438 let max = max_scroll.get();
439 scroll_position.set(value.clamp(0.0, max));
440 }
441 };
442
443 let track_length = {
444 let cached_bounds = cached_bounds.clone();
445 move || -> f32 {
446 let bounds = cached_bounds.get();
447 match orientation {
448 ScrollBarOrientation::Vertical => bounds.height,
449 ScrollBarOrientation::Horizontal => bounds.width,
450 }
451 }
452 };
453
454 let thumb_length = {
455 let viewport_ratio = viewport_ratio.clone();
456 let track_length = track_length.clone();
457 move || -> f32 {
458 let ratio = viewport_ratio.get().clamp(0.0, 1.0);
459 let track = track_length();
460 (track * ratio).max(min_thumb_length).min(track)
461 }
462 };
463
464 let thumb_rect = {
465 let cached_bounds = cached_bounds.clone();
466 let scroll_position = scroll_position.clone();
467 let max_scroll = max_scroll.clone();
468 let track_length = track_length.clone();
469 let thumb_length = thumb_length.clone();
470 let mirrored = mirrored.clone();
471 move || -> Rect {
472 let bounds = cached_bounds.get();
473 let max = max_scroll.get();
474 let offset = if max <= 0.0 {
475 0.0
476 } else {
477 let pos = scroll_position.get();
478 let ratio = (pos / max).clamp(0.0, 1.0);
479 let available = track_length() - thumb_length();
480 ratio * available
481 };
482 let tl = thumb_length();
483 match orientation {
487 ScrollBarOrientation::Vertical => Rect::new(0.0, offset, bounds.width, tl),
488 ScrollBarOrientation::Horizontal => {
489 let x = if mirrored() {
490 bounds.width - offset - tl
491 } else {
492 offset
493 };
494 Rect::new(x, 0.0, tl, bounds.height)
495 }
496 }
497 }
498 };
499
500 let on_thumb = {
505 let thumb_rect = thumb_rect.clone();
506 move |position: Point| -> bool {
507 let tr = thumb_rect();
508 let v = axis_value(position);
509 match orientation {
510 ScrollBarOrientation::Vertical => v >= tr.y && v <= tr.bottom(),
511 ScrollBarOrientation::Horizontal => v >= tr.x && v <= tr.right(),
512 }
513 }
514 };
515
516 let mut handlers = HandlerSet::new().focusable(false);
520
521 {
531 let dragging = dragging.clone();
532 let drag_start_pointer = drag_start_pointer.clone();
533 let drag_start_scroll = drag_start_scroll.clone();
534 let scroll_position = scroll_position.clone();
535 let max_scroll = max_scroll.clone();
536 let set_scroll = set_scroll.clone();
537 let on_thumb = on_thumb.clone();
538 let track_length = track_length.clone();
539 let thumb_length = thumb_length.clone();
540 let mirrored = mirrored.clone();
541 handlers = handlers.on_drag(move |phase, _ctx| {
542 let max = max_scroll.get();
543 if max <= 0.0 {
544 return;
545 }
546 match phase {
547 DragPhase::Started {
548 position,
549 button: PointerButton::Primary,
550 ..
551 } if on_thumb(position) => {
552 dragging.set(true);
553 drag_start_pointer.set(axis_value(position));
554 drag_start_scroll.set(scroll_position.get());
555 }
556 DragPhase::Moved { position, .. } if dragging.get() => {
557 let current = axis_value(position);
558 let sign = if mirrored() { -1.0 } else { 1.0 };
561 let delta_pixels = (current - drag_start_pointer.get()) * sign;
562 let available = track_length() - thumb_length();
563 if available > 0.0 {
564 let scroll_delta = delta_pixels * max / available;
565 set_scroll(drag_start_scroll.get() + scroll_delta);
566 }
567 }
568 DragPhase::Ended { .. } => {
569 dragging.set(false);
570 }
571 _ => {}
572 }
573 });
574 }
575
576 {
582 let scroll_position = scroll_position.clone();
583 let max_scroll = max_scroll.clone();
584 let viewport_ratio = viewport_ratio.clone();
585 let set_scroll = set_scroll.clone();
586 let thumb_rect = thumb_rect.clone();
587 let mirrored = mirrored.clone();
588 let on_thumb = on_thumb.clone();
589 handlers = handlers.on_tap(move |event, _ctx| {
590 let max = max_scroll.get();
591 if max <= 0.0 {
592 return;
593 }
594 let tr = thumb_rect();
595 let position = event.position;
596 if on_thumb(position) {
597 return;
598 }
599 let click_axis = axis_value(position);
600 let thumb_center = match orientation {
601 ScrollBarOrientation::Vertical => tr.y + tr.height / 2.0,
602 ScrollBarOrientation::Horizontal => tr.x + tr.width / 2.0,
603 };
604 let ratio = viewport_ratio.get().clamp(0.001, 0.999);
605 let viewport_scroll = max * ratio / (1.0 - ratio);
606 let current = scroll_position.get();
607 let backwards = (click_axis < thumb_center) != mirrored();
610 if backwards {
611 set_scroll(current - viewport_scroll);
612 } else {
613 set_scroll(current + viewport_scroll);
614 }
615 });
616 }
617
618 {
622 let hovered = hovered.clone();
623 handlers = handlers.on_hover(move |entered, _ctx| {
624 hovered.set(entered);
625 });
626 }
627
628 {
630 let scroll_position = scroll_position.clone();
631 let max_scroll = max_scroll.clone();
632 let set_scroll = set_scroll.clone();
633 let ratio = self.viewport_ratio.clone();
634 let mirrored = mirrored.clone();
635 handlers = handlers.on_key(move |event, _ctx| {
636 let max = max_scroll.get();
637 if max <= 0.0 {
638 return EventResponse::Ignored;
639 }
640 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
641 return EventResponse::Ignored;
642 };
643 let step = step_size;
644 let arrows = match orientation {
655 ScrollBarOrientation::Vertical => RangeAxis::Vertical,
656 ScrollBarOrientation::Horizontal => RangeAxis::Horizontal,
657 };
658 let Some(mv) =
659 range_nav::range_move(*key, *modifiers, RangeKind::Scalar, arrows, mirrored())
660 else {
661 return EventResponse::Ignored;
662 };
663 let horizontal = matches!(orientation, ScrollBarOrientation::Horizontal);
667 match mv {
668 RangeMove::Step { increase } => {
669 let d = if range_nav::towards_trailing(increase, horizontal) {
670 step
671 } else {
672 -step
673 };
674 set_scroll(scroll_position.get() + d);
675 }
676 RangeMove::Page { increase } => {
685 let p = page_step(&ratio, max, step);
686 set_scroll(scroll_position.get() + if increase { -p } else { p });
696 }
697 RangeMove::ToMin => set_scroll(0.0),
698 RangeMove::ToMax => set_scroll(max),
699 }
700 EventResponse::Handled
701 });
702 }
703
704 ctx.apply_self_handlers(handlers);
711
712 vec![body_id]
713 }
714
715 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
716 match self.orientation {
717 ScrollBarOrientation::Vertical => {
718 Size::new(self.thickness, proposal.height.unwrap_or(100.0))
719 }
720 ScrollBarOrientation::Horizontal => {
721 Size::new(proposal.width.unwrap_or(100.0), self.thickness)
722 }
723 }
724 .into()
725 }
726
727 fn place_children(
728 &self,
729 bounds: Rect,
730 _proposal: SizeProposal,
731 children: &mut [WidgetPlacement],
732 ctx: &LayoutContext,
733 ) {
734 self.cached_bounds.set(bounds);
739 self.cached_rtl.set(ctx.is_rtl());
740 for child in children.iter_mut() {
741 child.origin = bounds.origin();
742 child.size = bounds.size();
743 }
744 }
745
746 fn children(&self) -> Vec<WidgetId> {
747 self.body_id.into_iter().collect()
748 }
749
750 fn hit_outset(
764 &self,
765 kind: teksilo_tokens::PointerKind,
766 _tokens: &teksilo_tokens::InputTokens,
767 ) -> EdgeInsets {
768 if !matches!(kind, teksilo_tokens::PointerKind::Touch) {
769 return EdgeInsets::ZERO;
770 }
771 let grow = ((SCROLLBAR_COARSE_TARGET - self.thickness) / 2.0).max(0.0);
772 match self.orientation {
773 ScrollBarOrientation::Vertical => EdgeInsets::symmetric(grow, 0.0),
774 ScrollBarOrientation::Horizontal => EdgeInsets::symmetric(0.0, grow),
775 }
776 }
777
778 fn target_regions(&self, bounds: Rect) -> Vec<TargetRegion> {
789 if self.max_scroll.get() <= 0.0 {
790 return Vec::new();
791 }
792 let cached = self.cached_bounds.get();
796 let thumb = self.thumb_rect();
797 let thumb = Rect::new(
798 bounds.x + (thumb.x - cached.x),
799 bounds.y + (thumb.y - cached.y),
800 thumb.width,
801 thumb.height,
802 );
803 let mut regions = vec![TargetRegion::grab(thumb, SCROLLBAR_PART_THUMB)];
804 match self.orientation {
807 ScrollBarOrientation::Vertical => {
808 let before = thumb.y - bounds.y;
809 if before > 0.0 {
810 regions.push(TargetRegion::target(
811 Rect::new(bounds.x, bounds.y, bounds.width, before),
812 SCROLLBAR_PART_TRACK,
813 ));
814 }
815 let after = bounds.bottom() - thumb.bottom();
816 if after > 0.0 {
817 regions.push(TargetRegion::target(
818 Rect::new(bounds.x, thumb.bottom(), bounds.width, after),
819 SCROLLBAR_PART_TRACK,
820 ));
821 }
822 }
823 ScrollBarOrientation::Horizontal => {
824 let before = thumb.x - bounds.x;
825 if before > 0.0 {
826 regions.push(TargetRegion::target(
827 Rect::new(bounds.x, bounds.y, before, bounds.height),
828 SCROLLBAR_PART_TRACK,
829 ));
830 }
831 let after = bounds.right() - thumb.right();
832 if after > 0.0 {
833 regions.push(TargetRegion::target(
834 Rect::new(thumb.right(), bounds.y, after, bounds.height),
835 SCROLLBAR_PART_TRACK,
836 ));
837 }
838 }
839 }
840 regions
841 }
842
843 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
844 builder.set_hidden();
848 }
849}
850
851fn page_step(ratio: &teksilo_core::signal::Signal<f32>, max: f32, step: f32) -> f32 {
858 let r = ratio.get().clamp(0.0, 1.0);
859 if r <= 0.0 || r >= 1.0 {
860 return step;
861 }
862 let viewport = max * r / (1.0 - r);
863 if viewport.is_finite() && viewport > step {
864 viewport
865 } else {
866 step
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use teksilo_canvas::SizeProposal;
874 use teksilo_core::widget_tree::WidgetTree;
875
876 #[test]
880 fn thumb_color_override_threads_into_style_config() {
881 use std::cell::Cell;
882 use std::rc::Rc;
883 use teksilo_core::build_context::BuildContext;
884 use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
885
886 struct RecordingStyle(Rc<Cell<bool>>);
887 impl ScrollBarStyle for RecordingStyle {
888 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
889 self.0.set(cfg.thumb_color.is_some());
890 ctx.add(crate::primitives::Spacer::new())
891 }
892 }
893
894 let saw_override = Rc::new(Cell::new(false));
895 let mut tree = WidgetTree::new();
896 let bar = ScrollBar::new(
897 ScrollBarOrientation::Vertical,
898 Signal::new(0.0),
899 Signal::new(500.0),
900 Signal::new(0.5),
901 )
902 .style(RecordingStyle(saw_override.clone()))
903 .thumb_color(teksilo_tokens::TextRole::TooltipText);
904 tree.add(bar);
905 tree.layout(SizeProposal::exact(20.0, 200.0));
906 assert!(
907 saw_override.get(),
908 "ScrollBar::thumb_color must thread into ScrollBarStyleConfig::thumb_color"
909 );
910 }
911
912 fn focused_bar(ratio: f32, max: f32) -> (WidgetTree, Signal<f32>, WidgetId) {
922 let position = Signal::new(0.0_f32);
923 let mut tree = WidgetTree::new();
924 let id = tree.add(ScrollBar::new(
925 ScrollBarOrientation::Vertical,
926 position.clone(),
927 Signal::new(max),
928 Signal::new(ratio),
929 ));
930 tree.layout(SizeProposal::exact(20.0, 200.0));
931 tree.focus(id);
932 (tree, position, id)
933 }
934
935 #[test]
936 fn arrows_step_the_position() {
937 use teksilo_core::event::{Key, Modifiers};
938 let (mut tree, position, id) = focused_bar(0.5, 500.0);
939
940 assert_eq!(
944 tree.focused(),
945 Some(id),
946 "programmatic focus must land for any of these to mean anything"
947 );
948
949 tree.press_key(Key::ArrowDown, Modifiers::NONE);
950 let after = position.get();
951 assert!(after > 0.0, "ArrowDown scrolls down: {after}");
952
953 tree.press_key(Key::ArrowUp, Modifiers::NONE);
954 assert!(position.get() < after, "ArrowUp scrolls back");
955 }
956
957 #[test]
958 fn a_vertical_bar_ignores_the_horizontal_arrows() {
959 use teksilo_core::event::{Key, Modifiers};
962 let (mut tree, position, _) = focused_bar(0.5, 500.0);
963
964 for key in [Key::ArrowLeft, Key::ArrowRight] {
965 tree.press_key(key, Modifiers::NONE);
966 assert_eq!(position.get(), 0.0, "{key:?} is not a vertical bar's");
967 }
968 }
969
970 #[test]
971 fn home_and_end_reach_the_ends() {
972 use teksilo_core::event::{Key, Modifiers};
973 let (mut tree, position, _) = focused_bar(0.5, 500.0);
974
975 tree.press_key(Key::End, Modifiers::NONE);
976 assert_eq!(position.get(), 500.0);
977 tree.press_key(Key::Home, Modifiers::NONE);
978 assert_eq!(position.get(), 0.0);
979 }
980
981 #[test]
982 fn page_keys_move_one_measured_viewport() {
983 use teksilo_core::event::{Key, Modifiers};
987 let (mut tree, position, _) = focused_bar(0.2, 400.0);
988
989 tree.press_key(Key::PageDown, Modifiers::NONE);
990 assert!(
991 (position.get() - 100.0).abs() < 0.01,
992 "one viewport is 100, got {}",
993 position.get()
994 );
995 tree.press_key(Key::PageUp, Modifiers::NONE);
996 assert!(position.get().abs() < 0.01);
997 }
998
999 #[test]
1000 fn the_page_keys_read_the_content_not_the_screen() {
1001 use teksilo_core::event::{Key, Modifiers};
1007
1008 for orientation in [
1009 ScrollBarOrientation::Vertical,
1010 ScrollBarOrientation::Horizontal,
1011 ] {
1012 let position = Signal::new(200.0_f32);
1013 let mut tree = WidgetTree::new();
1014 let id = tree.add(ScrollBar::new(
1015 orientation,
1016 position.clone(),
1017 Signal::new(400.0),
1018 Signal::new(0.2),
1019 ));
1020 tree.layout(match orientation {
1021 ScrollBarOrientation::Vertical => SizeProposal::exact(20.0, 200.0),
1022 ScrollBarOrientation::Horizontal => SizeProposal::exact(200.0, 20.0),
1023 });
1024 tree.focus(id);
1025
1026 tree.press_key(Key::PageDown, Modifiers::NONE);
1027 assert!(
1028 position.get() > 200.0,
1029 "{orientation:?}: PageDown moves forward through the content, got {}",
1030 position.get()
1031 );
1032 let forward = position.get();
1033 tree.press_key(Key::PageUp, Modifiers::NONE);
1034 assert!(
1035 position.get() < forward,
1036 "{orientation:?}: PageUp moves back, got {}",
1037 position.get()
1038 );
1039 }
1040 }
1041
1042 #[test]
1043 fn an_accelerator_chord_does_not_scroll() {
1044 use teksilo_core::event::{Key, Modifiers};
1047 let (mut tree, position, _) = focused_bar(0.5, 500.0);
1048
1049 for (key, mods) in [
1050 (Key::End, Modifiers::CTRL),
1051 (Key::PageDown, Modifiers::ALT),
1052 (Key::ArrowDown, Modifiers::SUPER),
1053 ] {
1054 tree.press_key(key, mods);
1055 assert_eq!(
1056 position.get(),
1057 0.0,
1058 "{key:?} with {mods:?} must fall through"
1059 );
1060 }
1061 }
1062
1063 fn make_scrollbar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
1064 let position = Signal::new(0.0_f32);
1065 let max_scroll = Signal::new(500.0_f32);
1066 let viewport_ratio = Signal::new(0.5_f32); let bar = ScrollBar::new(
1069 ScrollBarOrientation::Vertical,
1070 position.clone(),
1071 max_scroll.clone(),
1072 viewport_ratio.clone(),
1073 );
1074 (bar, position, max_scroll, viewport_ratio)
1075 }
1076
1077 #[test]
1078 fn vertical_scrollbar_size() {
1079 let (bar, ..) = make_scrollbar();
1080 let mut tree = WidgetTree::new();
1081 let id = tree.add(bar);
1082 tree.layout(SizeProposal {
1083 width: None,
1084 height: Some(400.0),
1085 });
1086
1087 let bounds = tree.bounds(id);
1088 assert!((bounds.width - 8.0).abs() < 0.01);
1090 assert!((bounds.height - 400.0).abs() < 0.01);
1091 }
1092
1093 #[test]
1094 fn horizontal_scrollbar_size() {
1095 let position = Signal::new(0.0_f32);
1096 let max_scroll = Signal::new(500.0_f32);
1097 let viewport_ratio = Signal::new(0.5_f32);
1098
1099 let bar = ScrollBar::new(
1100 ScrollBarOrientation::Horizontal,
1101 position,
1102 max_scroll,
1103 viewport_ratio,
1104 );
1105 let mut tree = WidgetTree::new();
1106 let id = tree.add(bar);
1107 tree.layout(SizeProposal {
1108 width: Some(400.0),
1109 height: None,
1110 });
1111
1112 let bounds = tree.bounds(id);
1113 assert!((bounds.width - 400.0).abs() < 0.01);
1115 assert!((bounds.height - 8.0).abs() < 0.01);
1116 }
1117
1118 #[test]
1119 fn scrollbar_thumb_drag_updates_position() {
1120 let (bar, position, _max, _ratio) = make_scrollbar();
1121 let mut tree = WidgetTree::new();
1122 let _id = tree.add(bar);
1123 tree.layout(SizeProposal::exact(12.0, 400.0));
1124
1125 tree.render();
1127
1128 assert!((position.get() - 0.0).abs() < 0.01);
1130
1131 tree.pointer_move(Point::new(6.0, 10.0));
1133 tree.dispatch_event(WidgetEvent::pointer_down(
1134 Point::new(6.0, 10.0),
1135 PointerButton::Primary,
1136 teksilo_core::event::Modifiers::NONE,
1137 ));
1138
1139 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 20.0)));
1146 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 110.0)));
1147
1148 let pos = position.get();
1149 assert!(pos > 200.0, "Expected scroll > 200, got {}", pos);
1150 assert!(pos < 300.0, "Expected scroll < 300, got {}", pos);
1151 }
1152
1153 #[test]
1154 fn scrollbar_clamps_to_range() {
1155 let (bar, position, max_scroll, ..) = make_scrollbar();
1156 let mut tree = WidgetTree::new();
1157 tree.add(bar);
1158 tree.layout(SizeProposal::exact(12.0, 400.0));
1159 tree.render();
1160
1161 for _ in 0..5 {
1166 tree.pointer_move(Point::new(6.0, 390.0));
1167 tree.dispatch_event(WidgetEvent::pointer_down(
1168 Point::new(6.0, 390.0),
1169 PointerButton::Primary,
1170 teksilo_core::event::Modifiers::NONE,
1171 ));
1172 tree.dispatch_event(WidgetEvent::pointer_up(
1174 Point::new(6.0, 390.0),
1175 PointerButton::Primary,
1176 teksilo_core::event::Modifiers::NONE,
1177 ));
1178 }
1179
1180 let pos = position.get();
1181 let max = max_scroll.get();
1182 assert!(
1183 (pos - max).abs() < 0.01,
1184 "Expected pos to be clamped at max={}, got {}",
1185 max,
1186 pos,
1187 );
1188 }
1189
1190 #[test]
1191 fn scrollbar_nothing_to_scroll() {
1192 let position = Signal::new(0.0_f32);
1193 let max_scroll = Signal::new(0.0_f32); let viewport_ratio = Signal::new(1.0_f32);
1195
1196 let bar = ScrollBar::new(
1197 ScrollBarOrientation::Vertical,
1198 position,
1199 max_scroll,
1200 viewport_ratio,
1201 );
1202 let mut tree = WidgetTree::new();
1203 tree.add(bar);
1204 tree.layout(SizeProposal::exact(12.0, 400.0));
1205
1206 let frame = tree.render();
1207 assert!(
1210 frame.shapes.is_empty(),
1211 "Expected no rendering when nothing to scroll"
1212 );
1213 }
1214
1215 #[test]
1216 fn scrollbar_is_hidden_from_at() {
1217 let (bar, position, _max_scroll, _ratio) = make_scrollbar();
1220 position.set(100.0);
1221
1222 let mut tree = WidgetTree::new();
1223 let id = tree.add(bar);
1224 tree.layout(SizeProposal::exact(12.0, 400.0));
1225
1226 let info = tree.accessibility_node(id);
1227 assert!(info.is_hidden(), "ScrollBar must be hidden from AT");
1228 }
1229
1230 #[test]
1231 fn a_horizontal_bar_mirrors_its_track_in_rtl() {
1232 use teksilo_core::event::Modifiers;
1238
1239 let position = Signal::new(0.0_f32);
1240 let mut tree = WidgetTree::new();
1241 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1242 let _id = tree.add(ScrollBar::new(
1243 ScrollBarOrientation::Horizontal,
1244 position.clone(),
1245 Signal::new(500.0),
1246 Signal::new(0.5),
1247 ));
1248 tree.layout(SizeProposal::exact(400.0, 12.0));
1249 tree.render();
1250
1251 let p = Point::new(40.0, 6.0);
1254 tree.pointer_move(p);
1255 tree.dispatch_event(WidgetEvent::pointer_down(
1256 p,
1257 PointerButton::Primary,
1258 Modifiers::NONE,
1259 ));
1260 tree.dispatch_event(WidgetEvent::pointer_up(
1261 p,
1262 PointerButton::Primary,
1263 Modifiers::NONE,
1264 ));
1265
1266 assert!(
1267 position.get() > 0.0,
1268 "a click left of a right-anchored thumb pages forward, got {}",
1269 position.get()
1270 );
1271 }
1272
1273 #[test]
1274 fn a_horizontal_bar_mirrors_its_painted_thumb_in_rtl() {
1275 let position = Signal::new(0.0_f32);
1281 let mut tree = WidgetTree::new();
1282 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1283 tree.add(ScrollBar::new(
1284 ScrollBarOrientation::Horizontal,
1285 position.clone(),
1286 Signal::new(500.0),
1287 Signal::new(0.5),
1288 ));
1289 tree.layout(SizeProposal::exact(400.0, 12.0));
1290 let frame = tree.render();
1291
1292 let thumb = frame
1295 .shapes
1296 .iter()
1297 .find(|q| (q.screen[2] - 200.0).abs() < 1.0)
1298 .expect("the bar paints a 200 px thumb");
1299 assert!(
1300 (thumb.screen[0] - 200.0).abs() < 1.0,
1301 "the thumb must be flush right at scroll 0 under RTL, got x={}",
1302 thumb.screen[0]
1303 );
1304 }
1305
1306 #[test]
1307 fn a_horizontal_bar_mirrors_its_arrows_in_rtl() {
1308 use teksilo_core::event::{Key, Modifiers};
1311
1312 let position = Signal::new(200.0_f32);
1313 let mut tree = WidgetTree::new();
1314 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1315 let id = tree.add(ScrollBar::new(
1316 ScrollBarOrientation::Horizontal,
1317 position.clone(),
1318 Signal::new(500.0),
1319 Signal::new(0.5),
1320 ));
1321 tree.layout(SizeProposal::exact(400.0, 12.0));
1322 tree.focus(id);
1323
1324 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1325 assert!(
1326 position.get() > 200.0,
1327 "under RTL the leftward arrow travels forward through the content"
1328 );
1329 }
1330
1331 #[test]
1332 fn a_vertical_bar_ignores_the_layout_direction() {
1333 use teksilo_core::event::{Key, Modifiers};
1334
1335 let position = Signal::new(200.0_f32);
1336 let mut tree = WidgetTree::new();
1337 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1338 let id = tree.add(ScrollBar::new(
1339 ScrollBarOrientation::Vertical,
1340 position.clone(),
1341 Signal::new(500.0),
1342 Signal::new(0.5),
1343 ));
1344 tree.layout(SizeProposal::exact(12.0, 400.0));
1345 tree.focus(id);
1346
1347 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1348 assert!(
1349 position.get() > 200.0,
1350 "there is no leading/trailing on the vertical axis to mirror"
1351 );
1352 }
1353
1354 #[test]
1355 fn track_click_pages_forward() {
1356 let (bar, position, ..) = make_scrollbar();
1357 let mut tree = WidgetTree::new();
1358 let _id = tree.add(bar);
1359 tree.layout(SizeProposal::exact(12.0, 400.0));
1360 tree.render();
1361
1362 tree.pointer_move(Point::new(6.0, 350.0));
1367 tree.dispatch_event(WidgetEvent::pointer_down(
1368 Point::new(6.0, 350.0),
1369 PointerButton::Primary,
1370 teksilo_core::event::Modifiers::NONE,
1371 ));
1372 tree.dispatch_event(WidgetEvent::pointer_up(
1373 Point::new(6.0, 350.0),
1374 PointerButton::Primary,
1375 teksilo_core::event::Modifiers::NONE,
1376 ));
1377
1378 let pos = position.get();
1379 assert!(
1380 pos > 0.0,
1381 "Expected positive scroll after track click, got {}",
1382 pos
1383 );
1384 }
1385
1386 #[test]
1387 fn scrollbar_drag_inside_scroll_area_updates_position() {
1388 use crate::primitives::MinSize;
1394 use crate::scroll_area::{ScrollArea, ScrollBarMode};
1395 use teksilo_canvas::Point;
1396 use teksilo_core::event::{Modifiers, PointerButton};
1397
1398 let mut tree = WidgetTree::new();
1399 let content = MinSize::new(400.0, 800.0);
1402 let root = tree.add(
1403 ScrollArea::new()
1404 .child(content)
1405 .scroll_bar_style(ScrollBarMode::Permanent),
1406 );
1407 tree.layout(SizeProposal::exact(400.0, 400.0));
1408 tree.render();
1409
1410 let sb_id = tree.children(root)[1];
1413 let sb_bounds = tree.bounds(sb_id);
1414 assert!(
1415 sb_bounds.width > 0.0,
1416 "scrollbar should have non-zero width"
1417 );
1418 assert!(
1419 sb_bounds.height > 0.0,
1420 "scrollbar should have non-zero height"
1421 );
1422
1423 let thumb_cx = sb_bounds.x + sb_bounds.width / 2.0;
1425 let thumb_cy = sb_bounds.y + sb_bounds.height / 4.0;
1426 tree.pointer_move(Point::new(thumb_cx, thumb_cy));
1427 tree.dispatch_event(WidgetEvent::pointer_down(
1428 Point::new(thumb_cx, thumb_cy),
1429 PointerButton::Primary,
1430 Modifiers::NONE,
1431 ));
1432
1433 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1435 thumb_cx,
1436 thumb_cy + 10.0,
1437 )));
1438 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1440 thumb_cx,
1441 thumb_cy + 100.0,
1442 )));
1443 tree.dispatch_event(WidgetEvent::pointer_up(
1444 Point::new(thumb_cx, thumb_cy + 100.0),
1445 PointerButton::Primary,
1446 Modifiers::NONE,
1447 ));
1448
1449 tree.layout(SizeProposal::exact(400.0, 400.0));
1453
1454 let final_scroll = tree.hit_test(Point::new(1.0, 1.0)); let _ = final_scroll;
1459 let content_bounds = tree.bounds(tree.children(root)[0]);
1463 assert!(
1464 content_bounds.y < -1.0,
1465 "content should have scrolled up (y < 0); got y={}",
1466 content_bounds.y
1467 );
1468 }
1469
1470 #[test]
1471 fn drag_release_outside_does_not_stick() {
1472 let (bar, position, ..) = make_scrollbar();
1477 let mut tree = WidgetTree::new();
1478 let _id = tree.add(bar);
1479 tree.layout(SizeProposal::exact(12.0, 400.0));
1480 tree.render();
1481
1482 tree.pointer_move(Point::new(6.0, 10.0));
1484 tree.dispatch_event(WidgetEvent::pointer_down(
1485 Point::new(6.0, 10.0),
1486 PointerButton::Primary,
1487 teksilo_core::event::Modifiers::NONE,
1488 ));
1489
1490 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(200.0, 300.0)));
1492
1493 tree.dispatch_event(WidgetEvent::pointer_up(
1495 Point::new(200.0, 300.0),
1496 PointerButton::Primary,
1497 teksilo_core::event::Modifiers::NONE,
1498 ));
1499
1500 let pos_before = position.get();
1502 tree.pointer_move(Point::new(6.0, 50.0));
1503 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 50.0)));
1504
1505 let pos_after = position.get();
1506 assert!(
1507 (pos_after - pos_before).abs() < 0.01,
1508 "Hovering after release should not move scroll: before={}, after={}",
1509 pos_before,
1510 pos_after,
1511 );
1512 }
1513}
1514
1515#[cfg(test)]
1518mod touch_tests {
1519 use super::*;
1520 use teksilo_canvas::SizeProposal;
1521 use teksilo_core::event::{Modifiers, WidgetEvent};
1522 use teksilo_core::widget::{LayoutContext, Widget};
1523 use teksilo_core::widget_tree::WidgetTree;
1524 use teksilo_tokens::{InputTokens, PenKind, PointerKind, TargetDensity};
1525
1526 fn bar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
1529 let position = Signal::new(0.0_f32);
1530 let max_scroll = Signal::new(500.0_f32);
1531 let viewport_ratio = Signal::new(0.5_f32);
1532 let bar = ScrollBar::new(
1533 ScrollBarOrientation::Vertical,
1534 position.clone(),
1535 max_scroll.clone(),
1536 viewport_ratio.clone(),
1537 )
1538 .thickness(12.0);
1539 (bar, position, max_scroll, viewport_ratio)
1540 }
1541
1542 fn mounted() -> (WidgetTree, WidgetId, Signal<f32>) {
1543 let (bar, position, ..) = bar();
1544 let mut tree = WidgetTree::new();
1545 let id = tree.add(bar);
1546 tree.layout(SizeProposal::exact(12.0, 400.0));
1547 tree.render();
1548 (tree, id, position)
1549 }
1550
1551 fn regions_of(bar: &ScrollBar, bounds: Rect) -> Vec<TargetRegion> {
1557 let theme = teksilo_core::presets::intui::light();
1558 let ctx = LayoutContext::for_testing(&theme);
1559 bar.place_children(
1560 bounds,
1561 SizeProposal::exact(bounds.width, bounds.height),
1562 &mut [],
1563 &ctx,
1564 );
1565 bar.target_regions(bounds)
1566 }
1567
1568 #[test]
1575 fn target_regions_report_the_thumb_at_its_paint_rect() {
1576 let (bar, position, ..) = bar();
1577 let bounds = Rect::new(0.0, 0.0, 12.0, 400.0);
1578
1579 let regions = regions_of(&bar, bounds);
1580 let thumb = regions
1581 .iter()
1582 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1583 .expect("the thumb is reported");
1584 assert_eq!(thumb.role, TargetRole::Grab);
1585 assert!((thumb.rect.y - bounds.y).abs() < 0.01, "{:?}", thumb.rect);
1586 assert!((thumb.rect.height - 200.0).abs() < 0.01, "{:?}", thumb.rect);
1587 assert!((thumb.rect.width - 12.0).abs() < 0.01);
1588
1589 position.set(500.0);
1591 let regions = regions_of(&bar, bounds);
1592 let thumb = regions
1593 .iter()
1594 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1595 .expect("the thumb is reported");
1596 assert!(
1597 (thumb.rect.bottom() - bounds.bottom()).abs() < 0.01,
1598 "{:?}",
1599 thumb.rect
1600 );
1601 }
1602
1603 #[test]
1607 fn target_regions_answer_in_the_frame_they_were_asked_about() {
1608 let (bar, ..) = bar();
1609 let moved = Rect::new(180.0, 40.0, 12.0, 400.0);
1610 let thumb = regions_of(&bar, moved)
1611 .into_iter()
1612 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1613 .expect("the thumb");
1614 assert!((thumb.rect.x - 180.0).abs() < 0.01, "{:?}", thumb.rect);
1615 assert!((thumb.rect.y - 40.0).abs() < 0.01, "{:?}", thumb.rect);
1616 }
1617
1618 #[test]
1621 fn target_regions_report_the_paging_track_around_the_thumb() {
1622 let (bar, position, ..) = bar();
1623 position.set(250.0);
1624 let regions = regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0));
1625 let thumb = regions
1626 .iter()
1627 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1628 .expect("the thumb");
1629 let track: Vec<_> = regions
1630 .iter()
1631 .filter(|r| r.part == SCROLLBAR_PART_TRACK)
1632 .collect();
1633 assert_eq!(track.len(), 2, "one strip above the thumb and one below");
1634 assert!((track[0].rect.bottom() - thumb.rect.y).abs() < 0.01);
1635 assert!((track[1].rect.y - thumb.rect.bottom()).abs() < 0.01);
1636 }
1637
1638 #[test]
1640 fn a_bar_with_nothing_to_scroll_reports_no_targets() {
1641 let bar = ScrollBar::new(
1642 ScrollBarOrientation::Vertical,
1643 Signal::new(0.0),
1644 Signal::new(0.0),
1645 Signal::new(1.0),
1646 );
1647 assert!(regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0)).is_empty());
1648 }
1649
1650 #[test]
1655 fn a_finger_reaches_a_48_dp_bar_over_a_12_dp_paint() {
1656 let (bar, ..) = bar();
1657 let tokens = InputTokens::for_density(TargetDensity::Compact);
1658 let outset = bar.hit_outset(PointerKind::Touch, &tokens);
1659 assert_eq!(outset.leading, 18.0);
1660 assert_eq!(outset.trailing, 18.0);
1661 assert_eq!(outset.top, 0.0, "the track already spans the viewport");
1662 assert_eq!(outset.bottom, 0.0);
1663 assert_eq!(
1664 12.0 + outset.leading + outset.trailing,
1665 SCROLLBAR_COARSE_TARGET
1666 );
1667 }
1668
1669 #[test]
1673 fn a_precise_pointer_gets_no_outset_at_any_density() {
1674 let (bar, ..) = bar();
1675 for density in [
1676 TargetDensity::Compact,
1677 TargetDensity::Comfortable,
1678 TargetDensity::Touch,
1679 ] {
1680 let tokens = InputTokens::for_density(density);
1681 for kind in [PointerKind::Mouse, PointerKind::Pen(PenKind::Pen)] {
1682 assert_eq!(
1683 bar.hit_outset(kind, &tokens),
1684 EdgeInsets::ZERO,
1685 "{kind:?} at {density:?}"
1686 );
1687 }
1688 }
1689 }
1690
1691 #[test]
1693 fn a_horizontal_bar_grows_vertically() {
1694 let bar = ScrollBar::new(
1695 ScrollBarOrientation::Horizontal,
1696 Signal::new(0.0),
1697 Signal::new(500.0),
1698 Signal::new(0.5),
1699 )
1700 .thickness(8.0);
1701 let tokens = InputTokens::for_density(TargetDensity::Compact);
1702 let outset = bar.hit_outset(PointerKind::Touch, &tokens);
1703 assert_eq!(outset.top, 20.0);
1704 assert_eq!(outset.bottom, 20.0);
1705 assert_eq!(outset.leading, 0.0);
1706 assert_eq!(outset.trailing, 0.0);
1707 }
1708
1709 fn floor_seen_by_the_style(density: TargetDensity, explicit: Option<f32>) -> f32 {
1716 use std::cell::Cell;
1717 use std::rc::Rc;
1718 use teksilo_core::build_context::BuildContext;
1719 use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
1720
1721 struct Recording(Rc<Cell<f32>>);
1722 impl ScrollBarStyle for Recording {
1723 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
1724 self.0.set(cfg.min_thumb_length);
1725 ctx.add(crate::primitives::Spacer::new())
1726 }
1727 }
1728
1729 let seen = Rc::new(Cell::new(f32::NAN));
1730 let mut bar = ScrollBar::new(
1731 ScrollBarOrientation::Vertical,
1732 Signal::new(0.0),
1733 Signal::new(500.0),
1734 Signal::new(0.02),
1735 )
1736 .style(Recording(seen.clone()));
1737 if let Some(explicit) = explicit {
1738 bar = bar.min_thumb_length(explicit);
1739 }
1740 let mut tree = WidgetTree::new();
1741 tree.set_input_density(density);
1742 tree.add(bar);
1743 tree.layout(SizeProposal::exact(12.0, 400.0));
1744 seen.get()
1745 }
1746
1747 #[test]
1750 fn the_minimum_thumb_follows_the_density() {
1751 assert_eq!(floor_seen_by_the_style(TargetDensity::Compact, None), 24.0);
1752 assert_eq!(
1753 floor_seen_by_the_style(TargetDensity::Comfortable, None),
1754 32.0
1755 );
1756 assert_eq!(floor_seen_by_the_style(TargetDensity::Touch, None), 44.0);
1757 }
1758
1759 #[test]
1761 fn an_explicit_minimum_thumb_wins_over_the_density() {
1762 assert_eq!(
1763 floor_seen_by_the_style(TargetDensity::Touch, Some(60.0)),
1764 60.0
1765 );
1766 }
1767
1768 #[test]
1772 fn the_resolved_floor_reaches_the_reported_thumb() {
1773 let bar = ScrollBar::new(
1774 ScrollBarOrientation::Vertical,
1775 Signal::new(0.0),
1776 Signal::new(500.0),
1777 Signal::new(0.02),
1778 )
1779 .min_thumb_length(44.0);
1780 let thumb = regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0))
1781 .into_iter()
1782 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1783 .expect("the thumb");
1784 assert_eq!(thumb.rect.height, 44.0);
1785 }
1786
1787 #[test]
1793 fn the_thumb_is_grabbable_at_its_paint_rect() {
1794 let thumb = regions_of(&bar().0, Rect::new(0.0, 0.0, 12.0, 400.0))
1795 .into_iter()
1796 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1797 .expect("the thumb");
1798 let (mut tree, _id, position) = mounted();
1799 let grab = Point::new(thumb.rect.center().x, thumb.rect.center().y);
1800
1801 tree.pointer_move(grab);
1802 tree.dispatch_event(WidgetEvent::pointer_down(
1803 grab,
1804 PointerButton::Primary,
1805 Modifiers::NONE,
1806 ));
1807 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(grab.x, grab.y + 10.0)));
1808 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1809 grab.x,
1810 grab.y + 100.0,
1811 )));
1812 assert!(
1813 position.get() > 200.0,
1814 "the drag moved the thumb: {}",
1815 position.get()
1816 );
1817 }
1818
1819 #[test]
1825 fn a_finger_reaching_through_the_outset_grabs_the_thumb() {
1826 use teksilo_core::pointer::{
1827 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
1828 PointerSample,
1829 };
1830
1831 let (bar, position, ..) = bar();
1832 let mut tree = WidgetTree::new();
1833 let bar_id = tree.add(bar);
1834 tree.add(
1838 crate::primitives::HStack::new()
1839 .child(crate::primitives::Spacer::new())
1840 .child(bar_id),
1841 );
1842 tree.layout(SizeProposal::exact(200.0, 400.0));
1843 tree.render();
1844 let bounds = tree.bounds(bar_id);
1845 assert!((bounds.width - 12.0).abs() < 0.01, "the paint is unchanged");
1846
1847 let alloc = PointerIdAllocator::global();
1848 let device = BackendDeviceKey::new(0x5B47);
1849 let finger = alloc.begin(device, 1);
1850 alloc.end(device, 1);
1851 let sample = |phase: PointerPhase, at: Point| PointerSample {
1852 pointer: PointerInfo::touch(finger, EventTime::ZERO),
1853 phase,
1854 position: at,
1855 button: None,
1856 modifiers: Modifiers::NONE,
1857 coalesced: Vec::new(),
1858 };
1859
1860 let grab = Point::new(bounds.x - 13.0, 100.0);
1863 assert_eq!(
1864 tree.hit_test_for(grab, &PointerInfo::touch(finger, EventTime::ZERO)),
1865 Some(bar_id),
1866 "the outset put the finger on the bar"
1867 );
1868 assert_ne!(
1869 tree.hit_test(grab),
1870 Some(bar_id),
1871 "…and a mouse at the same point lands on the content beside it"
1872 );
1873 tree.dispatch_pointer(sample(PointerPhase::Down, grab));
1874 tree.dispatch_pointer(sample(
1875 PointerPhase::Move,
1876 Point::new(grab.x, grab.y + 30.0),
1877 ));
1878 tree.dispatch_pointer(sample(
1879 PointerPhase::Move,
1880 Point::new(grab.x, grab.y + 100.0),
1881 ));
1882 assert!(
1883 position.get() > 200.0,
1884 "the finger dragged the thumb rather than paging: {}",
1885 position.get()
1886 );
1887 }
1888
1889 #[test]
1891 fn a_track_tap_pages_toward_the_tap() {
1892 let (mut tree, _id, position) = mounted();
1893 let tap = Point::new(6.0, 390.0);
1894 tree.pointer_move(tap);
1895 tree.dispatch_event(WidgetEvent::pointer_down(
1896 tap,
1897 PointerButton::Primary,
1898 Modifiers::NONE,
1899 ));
1900 tree.dispatch_event(WidgetEvent::pointer_up(
1901 tap,
1902 PointerButton::Primary,
1903 Modifiers::NONE,
1904 ));
1905 assert_eq!(position.get(), 500.0);
1908
1909 let tap = Point::new(6.0, 10.0);
1911 tree.pointer_move(tap);
1912 tree.dispatch_event(WidgetEvent::pointer_down(
1913 tap,
1914 PointerButton::Primary,
1915 Modifiers::NONE,
1916 ));
1917 tree.dispatch_event(WidgetEvent::pointer_up(
1918 tap,
1919 PointerButton::Primary,
1920 Modifiers::NONE,
1921 ));
1922 assert_eq!(position.get(), 0.0);
1923 }
1924
1925 #[test]
1930 fn an_external_reveal_reads_as_hover_to_the_style() {
1931 use std::cell::Cell;
1932 use std::rc::Rc;
1933 use teksilo_core::build_context::BuildContext;
1934 use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
1935
1936 #[derive(Clone)]
1937 struct Recording(Rc<Cell<bool>>);
1938 impl ScrollBarStyle for Recording {
1939 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
1940 self.0.set(cfg.is_hovered.get());
1941 ctx.add(crate::primitives::Spacer::new())
1942 }
1943 }
1944
1945 let hovered = Rc::new(Cell::new(false));
1946 let revealed = Signal::new(false);
1947 let bar = ScrollBar::new(
1948 ScrollBarOrientation::Vertical,
1949 Signal::new(0.0),
1950 Signal::new(500.0),
1951 Signal::new(0.5),
1952 )
1953 .reveal(revealed.clone())
1954 .style(Recording(hovered.clone()));
1955 let mut tree = WidgetTree::new();
1956 tree.add(bar);
1957 tree.layout(SizeProposal::exact(12.0, 400.0));
1958 assert!(!hovered.get(), "nothing has revealed it yet");
1959
1960 revealed.set(true);
1961 tree.layout(SizeProposal::exact(12.0, 400.0));
1962 let bar = ScrollBar::new(
1963 ScrollBarOrientation::Vertical,
1964 Signal::new(0.0),
1965 Signal::new(500.0),
1966 Signal::new(0.5),
1967 )
1968 .reveal(revealed.clone())
1969 .style(Recording(hovered.clone()));
1970 let mut tree = WidgetTree::new();
1971 tree.add(bar);
1972 tree.layout(SizeProposal::exact(12.0, 400.0));
1973 assert!(hovered.get(), "a raised reveal shows the bar");
1974 }
1975
1976 #[test]
1979 fn a_touch_density_reveals_the_bar_at_rest() {
1980 let revealed = Signal::new(false);
1981 let bar = ScrollBar::new(
1982 ScrollBarOrientation::Vertical,
1983 Signal::new(0.0),
1984 Signal::new(500.0),
1985 Signal::new(0.5),
1986 )
1987 .reveal(revealed.clone());
1988 let mut tree = WidgetTree::new();
1989 tree.set_input_density(TargetDensity::Touch);
1990 tree.add(bar);
1991 tree.layout(SizeProposal::exact(12.0, 400.0));
1992 assert!(revealed.get(), "RevealPolicy::Always shows it at rest");
1993 }
1994
1995 #[test]
1998 fn a_compact_density_leaves_the_bar_at_rest() {
1999 let revealed = Signal::new(false);
2000 let bar = ScrollBar::new(
2001 ScrollBarOrientation::Vertical,
2002 Signal::new(0.0),
2003 Signal::new(500.0),
2004 Signal::new(0.5),
2005 )
2006 .reveal(revealed.clone());
2007 let mut tree = WidgetTree::new();
2008 tree.add(bar);
2009 tree.layout(SizeProposal::exact(12.0, 400.0));
2010 assert!(!revealed.get());
2011 }
2012}