1use std::cell::Cell;
61use std::rc::Rc;
62
63use teksilo_canvas::{EdgeInsets, Point, Rect, Size, SizeProposal};
64use teksilo_core::accessibility::AccessNodeBuilder;
65use teksilo_core::color_prop::ColorProp;
66use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
67use teksilo_core::gesture::DragPhase;
68use teksilo_core::partition::TargetRegion;
69use teksilo_core::signal::Signal;
70use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig, SharedScrollBarStyle};
71use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
72use teksilo_core::widget_builder::HandlerSet;
73use teksilo_core::widget_id::WidgetId;
74use teksilo_tokens::RevealPolicy;
75
76use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
77
78pub use teksilo_core::styles::ScrollBarOrientation;
83pub use teksilo_core::styles::ScrollBarVariant;
84pub use teksilo_core::styles::ScrollBarVariant as ScrollBarVisual;
85
86pub const SCROLLBAR_COARSE_TARGET: f32 = 48.0;
92
93pub const SCROLLBAR_MIN_THUMB_LENGTH: f32 = 24.0;
104
105pub const SCROLLBAR_PART_THUMB: u16 = 0;
110pub const SCROLLBAR_PART_TRACK: u16 = 1;
112
113pub struct ScrollBar {
118 orientation: ScrollBarOrientation,
119 scroll_position: Signal<f32>,
122 max_scroll: Signal<f32>,
125 viewport_ratio: Signal<f32>,
128
129 hovered: Signal<bool>,
132 dragging: Signal<bool>,
134 drag_start_pointer: Rc<Cell<f32>>,
136 drag_start_scroll: Rc<Cell<f32>>,
138 cached_bounds: Rc<Cell<Rect>>,
140 cached_rtl: Rc<Cell<bool>>,
144 body_id: Option<WidgetId>,
147
148 thickness: f32,
151 min_thumb_length: Option<f32>,
153 resolved_min_thumb_length: Rc<Cell<f32>>,
160 revealed: Signal<bool>,
164 step_size: f32,
166 variant: ScrollBarVariant,
168 style_override: Option<SharedScrollBarStyle>,
170 thumb_color: Option<ColorProp>,
175}
176
177impl std::fmt::Debug for ScrollBar {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 f.debug_struct("ScrollBar")
180 .field("orientation", &self.orientation)
181 .field("hovered", &self.hovered.get())
182 .field("dragging", &self.dragging.get())
183 .field("variant", &self.variant)
184 .finish()
185 }
186}
187
188impl ScrollBar {
189 pub fn new(
195 orientation: ScrollBarOrientation,
196 scroll_position: Signal<f32>,
197 max_scroll: Signal<f32>,
198 viewport_ratio: Signal<f32>,
199 ) -> Self {
200 Self {
203 orientation,
204 scroll_position,
205 max_scroll,
206 viewport_ratio,
207 hovered: Signal::new(false),
208 dragging: Signal::new(false),
209 drag_start_pointer: Rc::new(Cell::new(0.0)),
210 drag_start_scroll: Rc::new(Cell::new(0.0)),
211 cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
212 cached_rtl: Rc::new(Cell::new(false)),
213 body_id: None,
214 thickness: 8.0,
215 min_thumb_length: None,
216 resolved_min_thumb_length: Rc::new(Cell::new(SCROLLBAR_MIN_THUMB_LENGTH)),
217 revealed: Signal::new(false),
218 step_size: 40.0,
219 variant: ScrollBarVariant::default(),
220 style_override: None,
221 thumb_color: None,
222 }
223 }
224
225 pub fn thickness(mut self, thickness: f32) -> Self {
227 self.thickness = thickness;
228 self
229 }
230
231 pub fn min_thumb_length(mut self, len: f32) -> Self {
239 self.min_thumb_length = Some(len);
240 self
241 }
242
243 pub fn reveal(mut self, revealed: Signal<bool>) -> Self {
251 self.revealed = revealed;
252 self
253 }
254
255 pub fn step_size(mut self, step: f32) -> Self {
257 self.step_size = step;
258 self
259 }
260
261 pub fn visual(mut self, variant: ScrollBarVariant) -> Self {
265 self.variant = variant;
266 self
267 }
268
269 pub fn variant(mut self, variant: ScrollBarVariant) -> Self {
271 self.variant = variant;
272 self
273 }
274
275 pub fn style(mut self, style: impl ScrollBarStyle) -> Self {
277 self.style_override = Some(Rc::new(style));
278 self
279 }
280
281 pub fn thumb_color(mut self, color: impl Into<ColorProp>) -> Self {
290 self.thumb_color = Some(color.into());
291 self
292 }
293
294 fn track_length(&self) -> f32 {
299 let bounds = self.cached_bounds.get();
300 match self.orientation {
301 ScrollBarOrientation::Vertical => bounds.height,
302 ScrollBarOrientation::Horizontal => bounds.width,
303 }
304 }
305
306 fn min_thumb(&self) -> f32 {
309 self.min_thumb_length
310 .unwrap_or_else(|| self.resolved_min_thumb_length.get())
311 }
312
313 fn thumb_length(&self) -> f32 {
315 let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
316 let track = self.track_length();
317 (track * ratio).max(self.min_thumb()).min(track)
318 }
319
320 fn thumb_offset(&self) -> f32 {
322 let max = self.max_scroll.get();
323 if max <= 0.0 {
324 return 0.0;
325 }
326 let pos = self.scroll_position.get();
327 let ratio = (pos / max).clamp(0.0, 1.0);
328 let available = self.track_length() - self.thumb_length();
329 ratio * available
330 }
331
332 fn thumb_rect(&self) -> Rect {
335 let bounds = self.cached_bounds.get();
336 let offset = self.thumb_offset();
337 let thumb_len = self.thumb_length();
338 match self.orientation {
339 ScrollBarOrientation::Vertical => {
340 Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
341 }
342 ScrollBarOrientation::Horizontal => {
343 Rect::new(bounds.x + offset, bounds.y, thumb_len, bounds.height)
344 }
345 }
346 }
347}
348
349impl Widget for ScrollBar {
350 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
351 let style: SharedScrollBarStyle = self
354 .style_override
355 .clone()
356 .or_else(|| ctx.theme().style_slots.scroll_bar.clone())
357 .unwrap_or_else(|| {
358 Rc::new(crate::styles::RecipeScrollBarStyle::for_tokens(
359 &ctx.theme().input,
360 ))
361 });
362
363 let min_thumb_length = self
369 .min_thumb_length
370 .unwrap_or_else(|| style.min_thumb_length(&ctx.theme().input));
371 self.resolved_min_thumb_length.set(min_thumb_length);
372
373 if ctx.theme().input.reveal == RevealPolicy::Always && !self.revealed.get() {
376 self.revealed.set(true);
377 }
378
379 let scroll_ratio = self
382 .scroll_position
383 .zip(&self.max_scroll)
384 .map(|(pos, max)| {
385 if *max <= 0.0 {
386 0.0
387 } else {
388 (*pos / *max).clamp(0.0, 1.0)
389 }
390 });
391 let is_idle = self.max_scroll.map(|m| *m <= 0.0);
393
394 let cfg = ScrollBarStyleConfig {
395 scroll_ratio,
396 viewport_ratio: self.viewport_ratio.clone(),
397 is_hovered: self.hovered.or(&self.revealed),
401 is_dragging: self.dragging.clone(),
402 is_idle,
403 orientation: self.orientation,
404 variant: self.variant,
405 min_thumb_length,
406 thumb_color: self.thumb_color.clone(),
407 };
408 let body_id = style.make_body(&cfg, ctx);
409 self.body_id = Some(body_id);
410
411 let orientation = self.orientation;
412 let scroll_position = self.scroll_position.clone();
413 let max_scroll = self.max_scroll.clone();
414 let viewport_ratio = self.viewport_ratio.clone();
415 let hovered = self.hovered.clone();
416 let dragging = self.dragging.clone();
417 let drag_start_pointer = self.drag_start_pointer.clone();
418 let drag_start_scroll = self.drag_start_scroll.clone();
419 let cached_bounds = self.cached_bounds.clone();
420 let step_size = self.step_size;
421
422 let mirrored = {
428 let cached_rtl = self.cached_rtl.clone();
429 move || -> bool {
430 matches!(orientation, ScrollBarOrientation::Horizontal) && cached_rtl.get()
431 }
432 };
433
434 let axis_value = move |point: Point| -> f32 {
435 match orientation {
436 ScrollBarOrientation::Vertical => point.y,
437 ScrollBarOrientation::Horizontal => point.x,
438 }
439 };
440
441 let set_scroll = {
442 let scroll_position = scroll_position.clone();
443 let max_scroll = max_scroll.clone();
444 move |value: f32| {
445 let max = max_scroll.get();
446 scroll_position.set(value.clamp(0.0, max));
447 }
448 };
449
450 let track_length = {
451 let cached_bounds = cached_bounds.clone();
452 move || -> f32 {
453 let bounds = cached_bounds.get();
454 match orientation {
455 ScrollBarOrientation::Vertical => bounds.height,
456 ScrollBarOrientation::Horizontal => bounds.width,
457 }
458 }
459 };
460
461 let thumb_length = {
462 let viewport_ratio = viewport_ratio.clone();
463 let track_length = track_length.clone();
464 move || -> f32 {
465 let ratio = viewport_ratio.get().clamp(0.0, 1.0);
466 let track = track_length();
467 (track * ratio).max(min_thumb_length).min(track)
468 }
469 };
470
471 let thumb_rect = {
472 let cached_bounds = cached_bounds.clone();
473 let scroll_position = scroll_position.clone();
474 let max_scroll = max_scroll.clone();
475 let track_length = track_length.clone();
476 let thumb_length = thumb_length.clone();
477 let mirrored = mirrored.clone();
478 move || -> Rect {
479 let bounds = cached_bounds.get();
480 let max = max_scroll.get();
481 let offset = if max <= 0.0 {
482 0.0
483 } else {
484 let pos = scroll_position.get();
485 let ratio = (pos / max).clamp(0.0, 1.0);
486 let available = track_length() - thumb_length();
487 ratio * available
488 };
489 let tl = thumb_length();
490 match orientation {
494 ScrollBarOrientation::Vertical => Rect::new(0.0, offset, bounds.width, tl),
495 ScrollBarOrientation::Horizontal => {
496 let x = if mirrored() {
497 bounds.width - offset - tl
498 } else {
499 offset
500 };
501 Rect::new(x, 0.0, tl, bounds.height)
502 }
503 }
504 }
505 };
506
507 let on_thumb = {
512 let thumb_rect = thumb_rect.clone();
513 move |position: Point| -> bool {
514 let tr = thumb_rect();
515 let v = axis_value(position);
516 match orientation {
517 ScrollBarOrientation::Vertical => v >= tr.y && v <= tr.bottom(),
518 ScrollBarOrientation::Horizontal => v >= tr.x && v <= tr.right(),
519 }
520 }
521 };
522
523 let mut handlers = HandlerSet::new().focusable(false);
527
528 {
538 let dragging = dragging.clone();
539 let drag_start_pointer = drag_start_pointer.clone();
540 let drag_start_scroll = drag_start_scroll.clone();
541 let scroll_position = scroll_position.clone();
542 let max_scroll = max_scroll.clone();
543 let set_scroll = set_scroll.clone();
544 let on_thumb = on_thumb.clone();
545 let track_length = track_length.clone();
546 let thumb_length = thumb_length.clone();
547 let mirrored = mirrored.clone();
548 handlers = handlers.on_drag(move |phase, _ctx| {
549 let max = max_scroll.get();
550 if max <= 0.0 {
551 return;
552 }
553 match phase {
554 DragPhase::Started {
555 position,
556 button: PointerButton::Primary,
557 ..
558 } if on_thumb(position) => {
559 dragging.set(true);
560 drag_start_pointer.set(axis_value(position));
561 drag_start_scroll.set(scroll_position.get());
562 }
563 DragPhase::Moved { position, .. } if dragging.get() => {
564 let current = axis_value(position);
565 let sign = if mirrored() { -1.0 } else { 1.0 };
568 let delta_pixels = (current - drag_start_pointer.get()) * sign;
569 let available = track_length() - thumb_length();
570 if available > 0.0 {
571 let scroll_delta = delta_pixels * max / available;
572 set_scroll(drag_start_scroll.get() + scroll_delta);
573 }
574 }
575 DragPhase::Ended { .. } => {
576 dragging.set(false);
577 }
578 _ => {}
579 }
580 });
581 }
582
583 {
589 let scroll_position = scroll_position.clone();
590 let max_scroll = max_scroll.clone();
591 let viewport_ratio = viewport_ratio.clone();
592 let set_scroll = set_scroll.clone();
593 let thumb_rect = thumb_rect.clone();
594 let mirrored = mirrored.clone();
595 let on_thumb = on_thumb.clone();
596 handlers = handlers.on_tap(move |event, _ctx| {
597 let max = max_scroll.get();
598 if max <= 0.0 {
599 return;
600 }
601 let tr = thumb_rect();
602 let position = event.position;
603 if on_thumb(position) {
604 return;
605 }
606 let click_axis = axis_value(position);
607 let thumb_center = match orientation {
608 ScrollBarOrientation::Vertical => tr.y + tr.height / 2.0,
609 ScrollBarOrientation::Horizontal => tr.x + tr.width / 2.0,
610 };
611 let ratio = viewport_ratio.get().clamp(0.001, 0.999);
612 let viewport_scroll = max * ratio / (1.0 - ratio);
613 let current = scroll_position.get();
614 let backwards = (click_axis < thumb_center) != mirrored();
617 if backwards {
618 set_scroll(current - viewport_scroll);
619 } else {
620 set_scroll(current + viewport_scroll);
621 }
622 });
623 }
624
625 {
629 let hovered = hovered.clone();
630 handlers = handlers.on_hover(move |entered, _ctx| {
631 hovered.set(entered);
632 });
633 }
634
635 {
637 let scroll_position = scroll_position.clone();
638 let max_scroll = max_scroll.clone();
639 let set_scroll = set_scroll.clone();
640 let ratio = self.viewport_ratio.clone();
641 let mirrored = mirrored.clone();
642 handlers = handlers.on_key(move |event, _ctx| {
643 let max = max_scroll.get();
644 if max <= 0.0 {
645 return EventResponse::Ignored;
646 }
647 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
648 return EventResponse::Ignored;
649 };
650 let step = step_size;
651 let arrows = match orientation {
662 ScrollBarOrientation::Vertical => RangeAxis::Vertical,
663 ScrollBarOrientation::Horizontal => RangeAxis::Horizontal,
664 };
665 let Some(mv) =
666 range_nav::range_move(*key, *modifiers, RangeKind::Scalar, arrows, mirrored())
667 else {
668 return EventResponse::Ignored;
669 };
670 let horizontal = matches!(orientation, ScrollBarOrientation::Horizontal);
674 match mv {
675 RangeMove::Step { increase } => {
676 let d = if range_nav::towards_trailing(increase, horizontal) {
677 step
678 } else {
679 -step
680 };
681 set_scroll(scroll_position.get() + d);
682 }
683 RangeMove::Page { increase } => {
692 let p = page_step(&ratio, max, step);
693 set_scroll(scroll_position.get() + if increase { -p } else { p });
703 }
704 RangeMove::ToMin => set_scroll(0.0),
705 RangeMove::ToMax => set_scroll(max),
706 }
707 EventResponse::Handled
708 });
709 }
710
711 ctx.apply_self_handlers(handlers);
718
719 vec![body_id]
720 }
721
722 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
723 match self.orientation {
724 ScrollBarOrientation::Vertical => {
725 Size::new(self.thickness, proposal.height.unwrap_or(100.0))
726 }
727 ScrollBarOrientation::Horizontal => {
728 Size::new(proposal.width.unwrap_or(100.0), self.thickness)
729 }
730 }
731 .into()
732 }
733
734 fn place_children(
735 &self,
736 bounds: Rect,
737 _proposal: SizeProposal,
738 children: &mut [WidgetPlacement],
739 ctx: &LayoutContext,
740 ) {
741 self.cached_bounds.set(bounds);
746 self.cached_rtl.set(ctx.is_rtl());
747 for child in children.iter_mut() {
748 child.origin = bounds.origin();
749 child.size = bounds.size();
750 }
751 }
752
753 fn children(&self) -> Vec<WidgetId> {
754 self.body_id.into_iter().collect()
755 }
756
757 fn hit_outset(
771 &self,
772 kind: teksilo_tokens::PointerKind,
773 _tokens: &teksilo_tokens::InputTokens,
774 ) -> EdgeInsets {
775 if !matches!(kind, teksilo_tokens::PointerKind::Touch) {
776 return EdgeInsets::ZERO;
777 }
778 let grow = ((SCROLLBAR_COARSE_TARGET - self.thickness) / 2.0).max(0.0);
779 match self.orientation {
780 ScrollBarOrientation::Vertical => EdgeInsets::symmetric(grow, 0.0),
781 ScrollBarOrientation::Horizontal => EdgeInsets::symmetric(0.0, grow),
782 }
783 }
784
785 fn target_regions(&self, bounds: Rect) -> Vec<TargetRegion> {
796 if self.max_scroll.get() <= 0.0 {
797 return Vec::new();
798 }
799 let cached = self.cached_bounds.get();
803 let thumb = self.thumb_rect();
804 let thumb = Rect::new(
805 bounds.x + (thumb.x - cached.x),
806 bounds.y + (thumb.y - cached.y),
807 thumb.width,
808 thumb.height,
809 );
810 let mut regions = vec![TargetRegion::grab(thumb, SCROLLBAR_PART_THUMB)];
811 match self.orientation {
814 ScrollBarOrientation::Vertical => {
815 let before = thumb.y - bounds.y;
816 if before > 0.0 {
817 regions.push(TargetRegion::target(
818 Rect::new(bounds.x, bounds.y, bounds.width, before),
819 SCROLLBAR_PART_TRACK,
820 ));
821 }
822 let after = bounds.bottom() - thumb.bottom();
823 if after > 0.0 {
824 regions.push(TargetRegion::target(
825 Rect::new(bounds.x, thumb.bottom(), bounds.width, after),
826 SCROLLBAR_PART_TRACK,
827 ));
828 }
829 }
830 ScrollBarOrientation::Horizontal => {
831 let before = thumb.x - bounds.x;
832 if before > 0.0 {
833 regions.push(TargetRegion::target(
834 Rect::new(bounds.x, bounds.y, before, bounds.height),
835 SCROLLBAR_PART_TRACK,
836 ));
837 }
838 let after = bounds.right() - thumb.right();
839 if after > 0.0 {
840 regions.push(TargetRegion::target(
841 Rect::new(thumb.right(), bounds.y, after, bounds.height),
842 SCROLLBAR_PART_TRACK,
843 ));
844 }
845 }
846 }
847 regions
848 }
849
850 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
851 builder.set_hidden();
855 }
856}
857
858fn page_step(ratio: &teksilo_core::signal::Signal<f32>, max: f32, step: f32) -> f32 {
865 let r = ratio.get().clamp(0.0, 1.0);
866 if r <= 0.0 || r >= 1.0 {
867 return step;
868 }
869 let viewport = max * r / (1.0 - r);
870 if viewport.is_finite() && viewport > step {
871 viewport
872 } else {
873 step
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use teksilo_canvas::SizeProposal;
881 use teksilo_core::widget_tree::WidgetTree;
882
883 #[test]
887 fn thumb_color_override_threads_into_style_config() {
888 use std::cell::Cell;
889 use std::rc::Rc;
890 use teksilo_core::build_context::BuildContext;
891 use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
892
893 struct RecordingStyle(Rc<Cell<bool>>);
894 impl ScrollBarStyle for RecordingStyle {
895 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
896 self.0.set(cfg.thumb_color.is_some());
897 ctx.add(crate::primitives::Spacer::new())
898 }
899 }
900
901 let saw_override = Rc::new(Cell::new(false));
902 let mut tree = WidgetTree::new();
903 let bar = ScrollBar::new(
904 ScrollBarOrientation::Vertical,
905 Signal::new(0.0),
906 Signal::new(500.0),
907 Signal::new(0.5),
908 )
909 .style(RecordingStyle(saw_override.clone()))
910 .thumb_color(teksilo_tokens::TextRole::TooltipText);
911 tree.add(bar);
912 tree.layout(SizeProposal::exact(20.0, 200.0));
913 assert!(
914 saw_override.get(),
915 "ScrollBar::thumb_color must thread into ScrollBarStyleConfig::thumb_color"
916 );
917 }
918
919 fn focused_bar(ratio: f32, max: f32) -> (WidgetTree, Signal<f32>, WidgetId) {
929 let position = Signal::new(0.0_f32);
930 let mut tree = WidgetTree::new();
931 let id = tree.add(ScrollBar::new(
932 ScrollBarOrientation::Vertical,
933 position.clone(),
934 Signal::new(max),
935 Signal::new(ratio),
936 ));
937 tree.layout(SizeProposal::exact(20.0, 200.0));
938 tree.focus(id);
939 (tree, position, id)
940 }
941
942 #[test]
943 fn arrows_step_the_position() {
944 use teksilo_core::event::{Key, Modifiers};
945 let (mut tree, position, id) = focused_bar(0.5, 500.0);
946
947 assert_eq!(
951 tree.focused(),
952 Some(id),
953 "programmatic focus must land for any of these to mean anything"
954 );
955
956 tree.press_key(Key::ArrowDown, Modifiers::NONE);
957 let after = position.get();
958 assert!(after > 0.0, "ArrowDown scrolls down: {after}");
959
960 tree.press_key(Key::ArrowUp, Modifiers::NONE);
961 assert!(position.get() < after, "ArrowUp scrolls back");
962 }
963
964 #[test]
965 fn a_vertical_bar_ignores_the_horizontal_arrows() {
966 use teksilo_core::event::{Key, Modifiers};
969 let (mut tree, position, _) = focused_bar(0.5, 500.0);
970
971 for key in [Key::ArrowLeft, Key::ArrowRight] {
972 tree.press_key(key, Modifiers::NONE);
973 assert_eq!(position.get(), 0.0, "{key:?} is not a vertical bar's");
974 }
975 }
976
977 #[test]
978 fn home_and_end_reach_the_ends() {
979 use teksilo_core::event::{Key, Modifiers};
980 let (mut tree, position, _) = focused_bar(0.5, 500.0);
981
982 tree.press_key(Key::End, Modifiers::NONE);
983 assert_eq!(position.get(), 500.0);
984 tree.press_key(Key::Home, Modifiers::NONE);
985 assert_eq!(position.get(), 0.0);
986 }
987
988 #[test]
989 fn page_keys_move_one_measured_viewport() {
990 use teksilo_core::event::{Key, Modifiers};
994 let (mut tree, position, _) = focused_bar(0.2, 400.0);
995
996 tree.press_key(Key::PageDown, Modifiers::NONE);
997 assert!(
998 (position.get() - 100.0).abs() < 0.01,
999 "one viewport is 100, got {}",
1000 position.get()
1001 );
1002 tree.press_key(Key::PageUp, Modifiers::NONE);
1003 assert!(position.get().abs() < 0.01);
1004 }
1005
1006 #[test]
1007 fn the_page_keys_read_the_content_not_the_screen() {
1008 use teksilo_core::event::{Key, Modifiers};
1014
1015 for orientation in [
1016 ScrollBarOrientation::Vertical,
1017 ScrollBarOrientation::Horizontal,
1018 ] {
1019 let position = Signal::new(200.0_f32);
1020 let mut tree = WidgetTree::new();
1021 let id = tree.add(ScrollBar::new(
1022 orientation,
1023 position.clone(),
1024 Signal::new(400.0),
1025 Signal::new(0.2),
1026 ));
1027 tree.layout(match orientation {
1028 ScrollBarOrientation::Vertical => SizeProposal::exact(20.0, 200.0),
1029 ScrollBarOrientation::Horizontal => SizeProposal::exact(200.0, 20.0),
1030 });
1031 tree.focus(id);
1032
1033 tree.press_key(Key::PageDown, Modifiers::NONE);
1034 assert!(
1035 position.get() > 200.0,
1036 "{orientation:?}: PageDown moves forward through the content, got {}",
1037 position.get()
1038 );
1039 let forward = position.get();
1040 tree.press_key(Key::PageUp, Modifiers::NONE);
1041 assert!(
1042 position.get() < forward,
1043 "{orientation:?}: PageUp moves back, got {}",
1044 position.get()
1045 );
1046 }
1047 }
1048
1049 #[test]
1050 fn an_accelerator_chord_does_not_scroll() {
1051 use teksilo_core::event::{Key, Modifiers};
1054 let (mut tree, position, _) = focused_bar(0.5, 500.0);
1055
1056 for (key, mods) in [
1057 (Key::End, Modifiers::CTRL),
1058 (Key::PageDown, Modifiers::ALT),
1059 (Key::ArrowDown, Modifiers::SUPER),
1060 ] {
1061 tree.press_key(key, mods);
1062 assert_eq!(
1063 position.get(),
1064 0.0,
1065 "{key:?} with {mods:?} must fall through"
1066 );
1067 }
1068 }
1069
1070 fn make_scrollbar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
1071 let position = Signal::new(0.0_f32);
1072 let max_scroll = Signal::new(500.0_f32);
1073 let viewport_ratio = Signal::new(0.5_f32); let bar = ScrollBar::new(
1076 ScrollBarOrientation::Vertical,
1077 position.clone(),
1078 max_scroll.clone(),
1079 viewport_ratio.clone(),
1080 );
1081 (bar, position, max_scroll, viewport_ratio)
1082 }
1083
1084 #[test]
1085 fn vertical_scrollbar_size() {
1086 let (bar, ..) = make_scrollbar();
1087 let mut tree = WidgetTree::new();
1088 let id = tree.add(bar);
1089 tree.layout(SizeProposal {
1090 width: None,
1091 height: Some(400.0),
1092 });
1093
1094 let bounds = tree.bounds(id);
1095 assert!((bounds.width - 8.0).abs() < 0.01);
1097 assert!((bounds.height - 400.0).abs() < 0.01);
1098 }
1099
1100 #[test]
1101 fn horizontal_scrollbar_size() {
1102 let position = Signal::new(0.0_f32);
1103 let max_scroll = Signal::new(500.0_f32);
1104 let viewport_ratio = Signal::new(0.5_f32);
1105
1106 let bar = ScrollBar::new(
1107 ScrollBarOrientation::Horizontal,
1108 position,
1109 max_scroll,
1110 viewport_ratio,
1111 );
1112 let mut tree = WidgetTree::new();
1113 let id = tree.add(bar);
1114 tree.layout(SizeProposal {
1115 width: Some(400.0),
1116 height: None,
1117 });
1118
1119 let bounds = tree.bounds(id);
1120 assert!((bounds.width - 400.0).abs() < 0.01);
1122 assert!((bounds.height - 8.0).abs() < 0.01);
1123 }
1124
1125 #[test]
1126 fn scrollbar_thumb_drag_updates_position() {
1127 let (bar, position, _max, _ratio) = make_scrollbar();
1128 let mut tree = WidgetTree::new();
1129 let _id = tree.add(bar);
1130 tree.layout(SizeProposal::exact(12.0, 400.0));
1131
1132 tree.render();
1134
1135 assert!((position.get() - 0.0).abs() < 0.01);
1137
1138 tree.pointer_move(Point::new(6.0, 10.0));
1140 tree.dispatch_event(WidgetEvent::pointer_down(
1141 Point::new(6.0, 10.0),
1142 PointerButton::Primary,
1143 teksilo_core::event::Modifiers::NONE,
1144 ));
1145
1146 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 20.0)));
1153 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 110.0)));
1154
1155 let pos = position.get();
1156 assert!(pos > 200.0, "Expected scroll > 200, got {}", pos);
1157 assert!(pos < 300.0, "Expected scroll < 300, got {}", pos);
1158 }
1159
1160 #[test]
1161 fn scrollbar_clamps_to_range() {
1162 let (bar, position, max_scroll, ..) = make_scrollbar();
1163 let mut tree = WidgetTree::new();
1164 tree.add(bar);
1165 tree.layout(SizeProposal::exact(12.0, 400.0));
1166 tree.render();
1167
1168 for _ in 0..5 {
1173 tree.pointer_move(Point::new(6.0, 390.0));
1174 tree.dispatch_event(WidgetEvent::pointer_down(
1175 Point::new(6.0, 390.0),
1176 PointerButton::Primary,
1177 teksilo_core::event::Modifiers::NONE,
1178 ));
1179 tree.dispatch_event(WidgetEvent::pointer_up(
1181 Point::new(6.0, 390.0),
1182 PointerButton::Primary,
1183 teksilo_core::event::Modifiers::NONE,
1184 ));
1185 }
1186
1187 let pos = position.get();
1188 let max = max_scroll.get();
1189 assert!(
1190 (pos - max).abs() < 0.01,
1191 "Expected pos to be clamped at max={}, got {}",
1192 max,
1193 pos,
1194 );
1195 }
1196
1197 #[test]
1198 fn scrollbar_nothing_to_scroll() {
1199 let position = Signal::new(0.0_f32);
1200 let max_scroll = Signal::new(0.0_f32); let viewport_ratio = Signal::new(1.0_f32);
1202
1203 let bar = ScrollBar::new(
1204 ScrollBarOrientation::Vertical,
1205 position,
1206 max_scroll,
1207 viewport_ratio,
1208 );
1209 let mut tree = WidgetTree::new();
1210 tree.add(bar);
1211 tree.layout(SizeProposal::exact(12.0, 400.0));
1212
1213 let frame = tree.render();
1214 assert!(
1217 frame.shapes.is_empty(),
1218 "Expected no rendering when nothing to scroll"
1219 );
1220 }
1221
1222 #[test]
1223 fn scrollbar_is_hidden_from_at() {
1224 let (bar, position, _max_scroll, _ratio) = make_scrollbar();
1227 position.set(100.0);
1228
1229 let mut tree = WidgetTree::new();
1230 let id = tree.add(bar);
1231 tree.layout(SizeProposal::exact(12.0, 400.0));
1232
1233 let info = tree.accessibility_node(id);
1234 assert!(info.is_hidden(), "ScrollBar must be hidden from AT");
1235 }
1236
1237 #[test]
1238 fn a_horizontal_bar_mirrors_its_track_in_rtl() {
1239 use teksilo_core::event::Modifiers;
1245
1246 let position = Signal::new(0.0_f32);
1247 let mut tree = WidgetTree::new();
1248 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1249 let _id = tree.add(ScrollBar::new(
1250 ScrollBarOrientation::Horizontal,
1251 position.clone(),
1252 Signal::new(500.0),
1253 Signal::new(0.5),
1254 ));
1255 tree.layout(SizeProposal::exact(400.0, 12.0));
1256 tree.render();
1257
1258 let p = Point::new(40.0, 6.0);
1261 tree.pointer_move(p);
1262 tree.dispatch_event(WidgetEvent::pointer_down(
1263 p,
1264 PointerButton::Primary,
1265 Modifiers::NONE,
1266 ));
1267 tree.dispatch_event(WidgetEvent::pointer_up(
1268 p,
1269 PointerButton::Primary,
1270 Modifiers::NONE,
1271 ));
1272
1273 assert!(
1274 position.get() > 0.0,
1275 "a click left of a right-anchored thumb pages forward, got {}",
1276 position.get()
1277 );
1278 }
1279
1280 #[test]
1281 fn a_horizontal_bar_mirrors_its_painted_thumb_in_rtl() {
1282 let position = Signal::new(0.0_f32);
1288 let mut tree = WidgetTree::new();
1289 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1290 tree.add(ScrollBar::new(
1291 ScrollBarOrientation::Horizontal,
1292 position.clone(),
1293 Signal::new(500.0),
1294 Signal::new(0.5),
1295 ));
1296 tree.layout(SizeProposal::exact(400.0, 12.0));
1297 let frame = tree.render();
1298
1299 let thumb = frame
1302 .shapes
1303 .iter()
1304 .find(|q| (q.screen[2] - 200.0).abs() < 1.0)
1305 .expect("the bar paints a 200 px thumb");
1306 assert!(
1307 (thumb.screen[0] - 200.0).abs() < 1.0,
1308 "the thumb must be flush right at scroll 0 under RTL, got x={}",
1309 thumb.screen[0]
1310 );
1311 }
1312
1313 #[test]
1314 fn a_horizontal_bar_mirrors_its_arrows_in_rtl() {
1315 use teksilo_core::event::{Key, Modifiers};
1318
1319 let position = Signal::new(200.0_f32);
1320 let mut tree = WidgetTree::new();
1321 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1322 let id = tree.add(ScrollBar::new(
1323 ScrollBarOrientation::Horizontal,
1324 position.clone(),
1325 Signal::new(500.0),
1326 Signal::new(0.5),
1327 ));
1328 tree.layout(SizeProposal::exact(400.0, 12.0));
1329 tree.focus(id);
1330
1331 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1332 assert!(
1333 position.get() > 200.0,
1334 "under RTL the leftward arrow travels forward through the content"
1335 );
1336 }
1337
1338 #[test]
1339 fn a_vertical_bar_ignores_the_layout_direction() {
1340 use teksilo_core::event::{Key, Modifiers};
1341
1342 let position = Signal::new(200.0_f32);
1343 let mut tree = WidgetTree::new();
1344 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1345 let id = tree.add(ScrollBar::new(
1346 ScrollBarOrientation::Vertical,
1347 position.clone(),
1348 Signal::new(500.0),
1349 Signal::new(0.5),
1350 ));
1351 tree.layout(SizeProposal::exact(12.0, 400.0));
1352 tree.focus(id);
1353
1354 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1355 assert!(
1356 position.get() > 200.0,
1357 "there is no leading/trailing on the vertical axis to mirror"
1358 );
1359 }
1360
1361 #[test]
1362 fn track_click_pages_forward() {
1363 let (bar, position, ..) = make_scrollbar();
1364 let mut tree = WidgetTree::new();
1365 let _id = tree.add(bar);
1366 tree.layout(SizeProposal::exact(12.0, 400.0));
1367 tree.render();
1368
1369 tree.pointer_move(Point::new(6.0, 350.0));
1374 tree.dispatch_event(WidgetEvent::pointer_down(
1375 Point::new(6.0, 350.0),
1376 PointerButton::Primary,
1377 teksilo_core::event::Modifiers::NONE,
1378 ));
1379 tree.dispatch_event(WidgetEvent::pointer_up(
1380 Point::new(6.0, 350.0),
1381 PointerButton::Primary,
1382 teksilo_core::event::Modifiers::NONE,
1383 ));
1384
1385 let pos = position.get();
1386 assert!(
1387 pos > 0.0,
1388 "Expected positive scroll after track click, got {}",
1389 pos
1390 );
1391 }
1392
1393 #[test]
1394 fn scrollbar_drag_inside_scroll_area_updates_position() {
1395 use crate::primitives::MinSize;
1401 use crate::scroll_area::{ScrollArea, ScrollBarMode};
1402 use teksilo_canvas::Point;
1403 use teksilo_core::event::{Modifiers, PointerButton};
1404
1405 let mut tree = WidgetTree::new();
1406 let content = MinSize::new(400.0, 800.0);
1409 let root = tree.add(
1410 ScrollArea::new()
1411 .child(content)
1412 .scroll_bar_style(ScrollBarMode::Permanent),
1413 );
1414 tree.layout(SizeProposal::exact(400.0, 400.0));
1415 tree.render();
1416
1417 let sb_id = tree.children(root)[1];
1420 let sb_bounds = tree.bounds(sb_id);
1421 assert!(
1422 sb_bounds.width > 0.0,
1423 "scrollbar should have non-zero width"
1424 );
1425 assert!(
1426 sb_bounds.height > 0.0,
1427 "scrollbar should have non-zero height"
1428 );
1429
1430 let thumb_cx = sb_bounds.x + sb_bounds.width / 2.0;
1432 let thumb_cy = sb_bounds.y + sb_bounds.height / 4.0;
1433 tree.pointer_move(Point::new(thumb_cx, thumb_cy));
1434 tree.dispatch_event(WidgetEvent::pointer_down(
1435 Point::new(thumb_cx, thumb_cy),
1436 PointerButton::Primary,
1437 Modifiers::NONE,
1438 ));
1439
1440 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1442 thumb_cx,
1443 thumb_cy + 10.0,
1444 )));
1445 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1447 thumb_cx,
1448 thumb_cy + 100.0,
1449 )));
1450 tree.dispatch_event(WidgetEvent::pointer_up(
1451 Point::new(thumb_cx, thumb_cy + 100.0),
1452 PointerButton::Primary,
1453 Modifiers::NONE,
1454 ));
1455
1456 tree.layout(SizeProposal::exact(400.0, 400.0));
1460
1461 let final_scroll = tree.hit_test(Point::new(1.0, 1.0)); let _ = final_scroll;
1466 let content_bounds = tree.bounds(tree.children(root)[0]);
1470 assert!(
1471 content_bounds.y < -1.0,
1472 "content should have scrolled up (y < 0); got y={}",
1473 content_bounds.y
1474 );
1475 }
1476
1477 #[test]
1478 fn drag_release_outside_does_not_stick() {
1479 let (bar, position, ..) = make_scrollbar();
1484 let mut tree = WidgetTree::new();
1485 let _id = tree.add(bar);
1486 tree.layout(SizeProposal::exact(12.0, 400.0));
1487 tree.render();
1488
1489 tree.pointer_move(Point::new(6.0, 10.0));
1491 tree.dispatch_event(WidgetEvent::pointer_down(
1492 Point::new(6.0, 10.0),
1493 PointerButton::Primary,
1494 teksilo_core::event::Modifiers::NONE,
1495 ));
1496
1497 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(200.0, 300.0)));
1499
1500 tree.dispatch_event(WidgetEvent::pointer_up(
1502 Point::new(200.0, 300.0),
1503 PointerButton::Primary,
1504 teksilo_core::event::Modifiers::NONE,
1505 ));
1506
1507 let pos_before = position.get();
1509 tree.pointer_move(Point::new(6.0, 50.0));
1510 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(6.0, 50.0)));
1511
1512 let pos_after = position.get();
1513 assert!(
1514 (pos_after - pos_before).abs() < 0.01,
1515 "Hovering after release should not move scroll: before={}, after={}",
1516 pos_before,
1517 pos_after,
1518 );
1519 }
1520}
1521
1522#[cfg(test)]
1525mod touch_tests {
1526 use super::*;
1527 use teksilo_canvas::SizeProposal;
1528 use teksilo_core::event::{Modifiers, WidgetEvent};
1529 use teksilo_core::widget::{LayoutContext, Widget};
1530 use teksilo_core::widget_tree::WidgetTree;
1531 use teksilo_tokens::{InputTokens, PenKind, PointerKind, TargetDensity, TargetRole};
1532
1533 fn bar() -> (ScrollBar, Signal<f32>, Signal<f32>, Signal<f32>) {
1536 let position = Signal::new(0.0_f32);
1537 let max_scroll = Signal::new(500.0_f32);
1538 let viewport_ratio = Signal::new(0.5_f32);
1539 let bar = ScrollBar::new(
1540 ScrollBarOrientation::Vertical,
1541 position.clone(),
1542 max_scroll.clone(),
1543 viewport_ratio.clone(),
1544 )
1545 .thickness(12.0);
1546 (bar, position, max_scroll, viewport_ratio)
1547 }
1548
1549 fn mounted() -> (WidgetTree, WidgetId, Signal<f32>) {
1550 let (bar, position, ..) = bar();
1551 let mut tree = WidgetTree::new();
1552 let id = tree.add(bar);
1553 tree.layout(SizeProposal::exact(12.0, 400.0));
1554 tree.render();
1555 (tree, id, position)
1556 }
1557
1558 fn regions_of(bar: &ScrollBar, bounds: Rect) -> Vec<TargetRegion> {
1564 let theme = teksilo_core::presets::intui::light();
1565 let ctx = LayoutContext::for_testing(&theme);
1566 bar.place_children(
1567 bounds,
1568 SizeProposal::exact(bounds.width, bounds.height),
1569 &mut [],
1570 &ctx,
1571 );
1572 bar.target_regions(bounds)
1573 }
1574
1575 #[test]
1582 fn target_regions_report_the_thumb_at_its_paint_rect() {
1583 let (bar, position, ..) = bar();
1584 let bounds = Rect::new(0.0, 0.0, 12.0, 400.0);
1585
1586 let regions = regions_of(&bar, bounds);
1587 let thumb = regions
1588 .iter()
1589 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1590 .expect("the thumb is reported");
1591 assert_eq!(thumb.role, TargetRole::Grab);
1592 assert!((thumb.rect.y - bounds.y).abs() < 0.01, "{:?}", thumb.rect);
1593 assert!((thumb.rect.height - 200.0).abs() < 0.01, "{:?}", thumb.rect);
1594 assert!((thumb.rect.width - 12.0).abs() < 0.01);
1595
1596 position.set(500.0);
1598 let regions = regions_of(&bar, bounds);
1599 let thumb = regions
1600 .iter()
1601 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1602 .expect("the thumb is reported");
1603 assert!(
1604 (thumb.rect.bottom() - bounds.bottom()).abs() < 0.01,
1605 "{:?}",
1606 thumb.rect
1607 );
1608 }
1609
1610 #[test]
1614 fn target_regions_answer_in_the_frame_they_were_asked_about() {
1615 let (bar, ..) = bar();
1616 let moved = Rect::new(180.0, 40.0, 12.0, 400.0);
1617 let thumb = regions_of(&bar, moved)
1618 .into_iter()
1619 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1620 .expect("the thumb");
1621 assert!((thumb.rect.x - 180.0).abs() < 0.01, "{:?}", thumb.rect);
1622 assert!((thumb.rect.y - 40.0).abs() < 0.01, "{:?}", thumb.rect);
1623 }
1624
1625 #[test]
1628 fn target_regions_report_the_paging_track_around_the_thumb() {
1629 let (bar, position, ..) = bar();
1630 position.set(250.0);
1631 let regions = regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0));
1632 let thumb = regions
1633 .iter()
1634 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1635 .expect("the thumb");
1636 let track: Vec<_> = regions
1637 .iter()
1638 .filter(|r| r.part == SCROLLBAR_PART_TRACK)
1639 .collect();
1640 assert_eq!(track.len(), 2, "one strip above the thumb and one below");
1641 assert!((track[0].rect.bottom() - thumb.rect.y).abs() < 0.01);
1642 assert!((track[1].rect.y - thumb.rect.bottom()).abs() < 0.01);
1643 }
1644
1645 #[test]
1647 fn a_bar_with_nothing_to_scroll_reports_no_targets() {
1648 let bar = ScrollBar::new(
1649 ScrollBarOrientation::Vertical,
1650 Signal::new(0.0),
1651 Signal::new(0.0),
1652 Signal::new(1.0),
1653 );
1654 assert!(regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0)).is_empty());
1655 }
1656
1657 #[test]
1662 fn a_finger_reaches_a_48_dp_bar_over_a_12_dp_paint() {
1663 let (bar, ..) = bar();
1664 let tokens = InputTokens::for_density(TargetDensity::Compact);
1665 let outset = bar.hit_outset(PointerKind::Touch, &tokens);
1666 assert_eq!(outset.leading, 18.0);
1667 assert_eq!(outset.trailing, 18.0);
1668 assert_eq!(outset.top, 0.0, "the track already spans the viewport");
1669 assert_eq!(outset.bottom, 0.0);
1670 assert_eq!(
1671 12.0 + outset.leading + outset.trailing,
1672 SCROLLBAR_COARSE_TARGET
1673 );
1674 }
1675
1676 #[test]
1680 fn a_precise_pointer_gets_no_outset_at_any_density() {
1681 let (bar, ..) = bar();
1682 for density in [
1683 TargetDensity::Compact,
1684 TargetDensity::Comfortable,
1685 TargetDensity::Touch,
1686 ] {
1687 let tokens = InputTokens::for_density(density);
1688 for kind in [PointerKind::Mouse, PointerKind::Pen(PenKind::Pen)] {
1689 assert_eq!(
1690 bar.hit_outset(kind, &tokens),
1691 EdgeInsets::ZERO,
1692 "{kind:?} at {density:?}"
1693 );
1694 }
1695 }
1696 }
1697
1698 #[test]
1700 fn a_horizontal_bar_grows_vertically() {
1701 let bar = ScrollBar::new(
1702 ScrollBarOrientation::Horizontal,
1703 Signal::new(0.0),
1704 Signal::new(500.0),
1705 Signal::new(0.5),
1706 )
1707 .thickness(8.0);
1708 let tokens = InputTokens::for_density(TargetDensity::Compact);
1709 let outset = bar.hit_outset(PointerKind::Touch, &tokens);
1710 assert_eq!(outset.top, 20.0);
1711 assert_eq!(outset.bottom, 20.0);
1712 assert_eq!(outset.leading, 0.0);
1713 assert_eq!(outset.trailing, 0.0);
1714 }
1715
1716 fn floor_seen_by_the_style(density: TargetDensity, explicit: Option<f32>) -> f32 {
1723 use std::cell::Cell;
1724 use std::rc::Rc;
1725 use teksilo_core::build_context::BuildContext;
1726 use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
1727
1728 struct Recording(Rc<Cell<f32>>);
1729 impl ScrollBarStyle for Recording {
1730 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
1731 self.0.set(cfg.min_thumb_length);
1732 ctx.add(crate::primitives::Spacer::new())
1733 }
1734 }
1735
1736 let seen = Rc::new(Cell::new(f32::NAN));
1737 let mut bar = ScrollBar::new(
1738 ScrollBarOrientation::Vertical,
1739 Signal::new(0.0),
1740 Signal::new(500.0),
1741 Signal::new(0.02),
1742 )
1743 .style(Recording(seen.clone()));
1744 if let Some(explicit) = explicit {
1745 bar = bar.min_thumb_length(explicit);
1746 }
1747 let mut tree = WidgetTree::new();
1748 tree.set_input_density(density);
1749 tree.add(bar);
1750 tree.layout(SizeProposal::exact(12.0, 400.0));
1751 seen.get()
1752 }
1753
1754 #[test]
1757 fn the_minimum_thumb_follows_the_density() {
1758 assert_eq!(floor_seen_by_the_style(TargetDensity::Compact, None), 24.0);
1759 assert_eq!(
1760 floor_seen_by_the_style(TargetDensity::Comfortable, None),
1761 32.0
1762 );
1763 assert_eq!(floor_seen_by_the_style(TargetDensity::Touch, None), 44.0);
1764 }
1765
1766 #[test]
1768 fn an_explicit_minimum_thumb_wins_over_the_density() {
1769 assert_eq!(
1770 floor_seen_by_the_style(TargetDensity::Touch, Some(60.0)),
1771 60.0
1772 );
1773 }
1774
1775 #[test]
1779 fn the_resolved_floor_reaches_the_reported_thumb() {
1780 let bar = ScrollBar::new(
1781 ScrollBarOrientation::Vertical,
1782 Signal::new(0.0),
1783 Signal::new(500.0),
1784 Signal::new(0.02),
1785 )
1786 .min_thumb_length(44.0);
1787 let thumb = regions_of(&bar, Rect::new(0.0, 0.0, 12.0, 400.0))
1788 .into_iter()
1789 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1790 .expect("the thumb");
1791 assert_eq!(thumb.rect.height, 44.0);
1792 }
1793
1794 struct FloorStyle {
1797 floor: f32,
1798 seen: std::rc::Rc<std::cell::Cell<f32>>,
1799 }
1800
1801 impl ScrollBarStyle for FloorStyle {
1802 fn make_body(
1803 &self,
1804 cfg: &ScrollBarStyleConfig,
1805 ctx: &mut teksilo_core::build_context::BuildContext,
1806 ) -> WidgetId {
1807 self.seen.set(cfg.min_thumb_length);
1808 ctx.add(crate::primitives::Spacer::new())
1809 }
1810
1811 fn min_thumb_length(&self, _tokens: &InputTokens) -> f32 {
1812 self.floor
1813 }
1814 }
1815
1816 fn floor_and_thumb(
1822 density: TargetDensity,
1823 floor: f32,
1824 in_theme_slot: bool,
1825 explicit: Option<f32>,
1826 ) -> (f32, f32) {
1827 let seen = Rc::new(Cell::new(f32::NAN));
1828 let style = FloorStyle {
1829 floor,
1830 seen: seen.clone(),
1831 };
1832 let mut bar = ScrollBar::new(
1833 ScrollBarOrientation::Vertical,
1834 Signal::new(0.0),
1835 Signal::new(500.0),
1836 Signal::new(0.02),
1837 );
1838 let mut theme = teksilo_core::presets::intui::light().with_density(density);
1839 if in_theme_slot {
1840 theme.style_slots.scroll_bar = Some(Rc::new(style));
1841 } else {
1842 bar = bar.style(style);
1843 }
1844 if let Some(explicit) = explicit {
1845 bar = bar.min_thumb_length(explicit);
1846 }
1847 let mut tree = WidgetTree::new().with_theme(theme);
1848 let id = tree.add(bar);
1849 tree.layout(SizeProposal::exact(12.0, 400.0));
1850 let thumb = tree
1851 .widget_target_regions(id)
1852 .into_iter()
1853 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1854 .expect("the thumb is reported");
1855 (seen.get(), thumb.rect.height)
1856 }
1857
1858 #[test]
1864 fn the_minimum_thumb_is_the_one_the_style_declares() {
1865 for density in [
1866 TargetDensity::Compact,
1867 TargetDensity::Comfortable,
1868 TargetDensity::Touch,
1869 ] {
1870 for in_theme_slot in [false, true] {
1871 assert_eq!(
1872 floor_and_thumb(density, 37.0, in_theme_slot, None),
1873 (37.0, 37.0),
1874 "{density:?}, theme slot: {in_theme_slot}",
1875 );
1876 }
1877 }
1878 }
1879
1880 #[test]
1883 fn an_explicit_minimum_thumb_wins_over_the_style() {
1884 assert_eq!(
1885 floor_and_thumb(TargetDensity::Compact, 37.0, false, Some(60.0)),
1886 (60.0, 60.0),
1887 );
1888 assert_eq!(
1889 floor_and_thumb(TargetDensity::Touch, 37.0, true, Some(20.0)),
1890 (20.0, 20.0),
1891 );
1892 }
1893
1894 #[test]
1896 fn a_style_floor_longer_than_the_track_is_clamped_to_it() {
1897 assert_eq!(
1898 floor_and_thumb(TargetDensity::Compact, 900.0, false, None),
1899 (900.0, 400.0),
1900 );
1901 }
1902
1903 #[test]
1909 fn the_thumb_is_grabbable_at_its_paint_rect() {
1910 let thumb = regions_of(&bar().0, Rect::new(0.0, 0.0, 12.0, 400.0))
1911 .into_iter()
1912 .find(|r| r.part == SCROLLBAR_PART_THUMB)
1913 .expect("the thumb");
1914 let (mut tree, _id, position) = mounted();
1915 let grab = Point::new(thumb.rect.center().x, thumb.rect.center().y);
1916
1917 tree.pointer_move(grab);
1918 tree.dispatch_event(WidgetEvent::pointer_down(
1919 grab,
1920 PointerButton::Primary,
1921 Modifiers::NONE,
1922 ));
1923 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(grab.x, grab.y + 10.0)));
1924 tree.dispatch_event(WidgetEvent::pointer_move(Point::new(
1925 grab.x,
1926 grab.y + 100.0,
1927 )));
1928 assert!(
1929 position.get() > 200.0,
1930 "the drag moved the thumb: {}",
1931 position.get()
1932 );
1933 }
1934
1935 #[test]
1941 fn a_finger_reaching_through_the_outset_grabs_the_thumb() {
1942 use teksilo_core::pointer::{
1943 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
1944 PointerSample,
1945 };
1946
1947 let (bar, position, ..) = bar();
1948 let mut tree = WidgetTree::new();
1949 let bar_id = tree.add(bar);
1950 tree.add(
1954 crate::primitives::HStack::new()
1955 .child(crate::primitives::Spacer::new())
1956 .child(bar_id),
1957 );
1958 tree.layout(SizeProposal::exact(200.0, 400.0));
1959 tree.render();
1960 let bounds = tree.bounds(bar_id);
1961 assert!((bounds.width - 12.0).abs() < 0.01, "the paint is unchanged");
1962
1963 let alloc = PointerIdAllocator::global();
1964 let device = BackendDeviceKey::new(0x5B47);
1965 let finger = alloc.begin(device, 1);
1966 alloc.end(device, 1);
1967 let sample = |phase: PointerPhase, at: Point| PointerSample {
1968 pointer: PointerInfo::touch(finger, EventTime::ZERO),
1969 phase,
1970 position: at,
1971 button: None,
1972 modifiers: Modifiers::NONE,
1973 coalesced: Vec::new(),
1974 };
1975
1976 let grab = Point::new(bounds.x - 13.0, 100.0);
1979 assert_eq!(
1980 tree.hit_test_for(grab, &PointerInfo::touch(finger, EventTime::ZERO)),
1981 Some(bar_id),
1982 "the outset put the finger on the bar"
1983 );
1984 assert_ne!(
1985 tree.hit_test(grab),
1986 Some(bar_id),
1987 "…and a mouse at the same point lands on the content beside it"
1988 );
1989 tree.dispatch_pointer(sample(PointerPhase::Down, grab));
1990 tree.dispatch_pointer(sample(
1991 PointerPhase::Move,
1992 Point::new(grab.x, grab.y + 30.0),
1993 ));
1994 tree.dispatch_pointer(sample(
1995 PointerPhase::Move,
1996 Point::new(grab.x, grab.y + 100.0),
1997 ));
1998 assert!(
1999 position.get() > 200.0,
2000 "the finger dragged the thumb rather than paging: {}",
2001 position.get()
2002 );
2003 }
2004
2005 #[test]
2007 fn a_track_tap_pages_toward_the_tap() {
2008 let (mut tree, _id, position) = mounted();
2009 let tap = Point::new(6.0, 390.0);
2010 tree.pointer_move(tap);
2011 tree.dispatch_event(WidgetEvent::pointer_down(
2012 tap,
2013 PointerButton::Primary,
2014 Modifiers::NONE,
2015 ));
2016 tree.dispatch_event(WidgetEvent::pointer_up(
2017 tap,
2018 PointerButton::Primary,
2019 Modifiers::NONE,
2020 ));
2021 assert_eq!(position.get(), 500.0);
2024
2025 let tap = Point::new(6.0, 10.0);
2027 tree.pointer_move(tap);
2028 tree.dispatch_event(WidgetEvent::pointer_down(
2029 tap,
2030 PointerButton::Primary,
2031 Modifiers::NONE,
2032 ));
2033 tree.dispatch_event(WidgetEvent::pointer_up(
2034 tap,
2035 PointerButton::Primary,
2036 Modifiers::NONE,
2037 ));
2038 assert_eq!(position.get(), 0.0);
2039 }
2040
2041 #[test]
2046 fn an_external_reveal_reads_as_hover_to_the_style() {
2047 use std::cell::Cell;
2048 use std::rc::Rc;
2049 use teksilo_core::build_context::BuildContext;
2050 use teksilo_core::styles::{ScrollBarStyle, ScrollBarStyleConfig};
2051
2052 #[derive(Clone)]
2053 struct Recording(Rc<Cell<bool>>);
2054 impl ScrollBarStyle for Recording {
2055 fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
2056 self.0.set(cfg.is_hovered.get());
2057 ctx.add(crate::primitives::Spacer::new())
2058 }
2059 }
2060
2061 let hovered = Rc::new(Cell::new(false));
2062 let revealed = Signal::new(false);
2063 let bar = ScrollBar::new(
2064 ScrollBarOrientation::Vertical,
2065 Signal::new(0.0),
2066 Signal::new(500.0),
2067 Signal::new(0.5),
2068 )
2069 .reveal(revealed.clone())
2070 .style(Recording(hovered.clone()));
2071 let mut tree = WidgetTree::new();
2072 tree.add(bar);
2073 tree.layout(SizeProposal::exact(12.0, 400.0));
2074 assert!(!hovered.get(), "nothing has revealed it yet");
2075
2076 revealed.set(true);
2077 tree.layout(SizeProposal::exact(12.0, 400.0));
2078 let bar = ScrollBar::new(
2079 ScrollBarOrientation::Vertical,
2080 Signal::new(0.0),
2081 Signal::new(500.0),
2082 Signal::new(0.5),
2083 )
2084 .reveal(revealed.clone())
2085 .style(Recording(hovered.clone()));
2086 let mut tree = WidgetTree::new();
2087 tree.add(bar);
2088 tree.layout(SizeProposal::exact(12.0, 400.0));
2089 assert!(hovered.get(), "a raised reveal shows the bar");
2090 }
2091
2092 #[test]
2095 fn a_touch_density_reveals_the_bar_at_rest() {
2096 let revealed = Signal::new(false);
2097 let bar = ScrollBar::new(
2098 ScrollBarOrientation::Vertical,
2099 Signal::new(0.0),
2100 Signal::new(500.0),
2101 Signal::new(0.5),
2102 )
2103 .reveal(revealed.clone());
2104 let mut tree = WidgetTree::new();
2105 tree.set_input_density(TargetDensity::Touch);
2106 tree.add(bar);
2107 tree.layout(SizeProposal::exact(12.0, 400.0));
2108 assert!(revealed.get(), "RevealPolicy::Always shows it at rest");
2109 }
2110
2111 #[test]
2114 fn a_compact_density_leaves_the_bar_at_rest() {
2115 let revealed = Signal::new(false);
2116 let bar = ScrollBar::new(
2117 ScrollBarOrientation::Vertical,
2118 Signal::new(0.0),
2119 Signal::new(500.0),
2120 Signal::new(0.5),
2121 )
2122 .reveal(revealed.clone());
2123 let mut tree = WidgetTree::new();
2124 tree.add(bar);
2125 tree.layout(SizeProposal::exact(12.0, 400.0));
2126 assert!(!revealed.get());
2127 }
2128}