1#[cfg(feature = "crossterm")]
53use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
54use ratatui_core::buffer::Buffer;
55use ratatui_core::layout::Rect;
56use ratatui_core::style::Style;
57use ratatui_core::widgets::Widget;
58
59use crate::metrics::{CellFill, HitTest, ScrollMetrics, SUBCELL};
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ScrollBarOrientation {
66 Vertical,
68 Horizontal,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum TrackClickBehavior {
77 Page,
79 JumpToClick,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ScrollBarArrows {
86 None,
88 Start,
90 End,
92 Both,
94}
95
96impl ScrollBarArrows {
97 const fn has_start(self) -> bool {
98 matches!(self, Self::Start | Self::Both)
99 }
100
101 const fn has_end(self) -> bool {
102 matches!(self, Self::End | Self::Both)
103 }
104}
105
106impl Default for ScrollBarArrows {
107 fn default() -> Self {
108 Self::Both
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum ScrollCommand {
117 SetOffset(usize),
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum ScrollAxis {
126 Vertical,
128 Horizontal,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum PointerButton {
135 Primary,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum PointerEventKind {
142 Down,
144 Drag,
146 Up,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct PointerEvent {
155 pub column: u16,
157 pub row: u16,
159 pub kind: PointerEventKind,
161 pub button: PointerButton,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub struct ScrollWheel {
171 pub axis: ScrollAxis,
173 pub delta: isize,
175 pub column: u16,
177 pub row: u16,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ScrollEvent {
186 Pointer(PointerEvent),
188 ScrollWheel(ScrollWheel),
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193enum ArrowHit {
194 Start,
195 End,
196}
197
198#[derive(Debug, Clone, Copy)]
199struct ArrowLayout {
200 track_area: Rect,
201 start: Option<(u16, u16)>,
202 end: Option<(u16, u16)>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct ScrollBarInteraction {
210 drag_state: DragState,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215enum DragState {
216 Idle,
218 Dragging { grab_offset: usize },
220}
221
222impl Default for DragState {
223 fn default() -> Self {
224 Self::Idle
225 }
226}
227
228impl Default for ScrollBarInteraction {
229 fn default() -> Self {
230 Self {
231 drag_state: DragState::default(),
232 }
233 }
234}
235
236impl ScrollBarInteraction {
237 pub fn new() -> Self {
239 Self::default()
240 }
241
242 fn start_drag(&mut self, grab_offset: usize) {
243 self.drag_state = DragState::Dragging { grab_offset };
244 }
245
246 fn stop_drag(&mut self) {
247 self.drag_state = DragState::Idle;
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct GlyphSet {
256 pub track_vertical: char,
258 pub track_horizontal: char,
260 pub arrow_vertical_start: char,
262 pub arrow_vertical_end: char,
264 pub arrow_horizontal_start: char,
266 pub arrow_horizontal_end: char,
268 pub thumb_vertical_lower: [char; 8],
270 pub thumb_vertical_upper: [char; 8],
272 pub thumb_horizontal_left: [char; 8],
274 pub thumb_horizontal_right: [char; 8],
276}
277
278impl GlyphSet {
279 pub const fn legacy() -> Self {
284 let vertical_lower = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
285 let vertical_upper = ['▔', '🮂', '🮃', '▀', '🮄', '🮅', '🮆', '█'];
286 let horizontal_left = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
287 let horizontal_right = ['▕', '🮇', '🮈', '▐', '🮉', '🮊', '🮋', '█'];
288 Self {
289 track_vertical: '│',
290 track_horizontal: '─',
291 arrow_vertical_start: '▲',
292 arrow_vertical_end: '▼',
293 arrow_horizontal_start: '◄',
294 arrow_horizontal_end: '►',
295 thumb_vertical_lower: vertical_lower,
296 thumb_vertical_upper: vertical_upper,
297 thumb_horizontal_left: horizontal_left,
298 thumb_horizontal_right: horizontal_right,
299 }
300 }
301
302 pub const fn unicode() -> Self {
307 let vertical = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
308 let horizontal = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
309 Self {
310 track_vertical: '│',
311 track_horizontal: '─',
312 arrow_vertical_start: '▲',
313 arrow_vertical_end: '▼',
314 arrow_horizontal_start: '◄',
315 arrow_horizontal_end: '►',
316 thumb_vertical_lower: vertical,
317 thumb_vertical_upper: vertical,
318 thumb_horizontal_left: horizontal,
319 thumb_horizontal_right: horizontal,
320 }
321 }
322}
323
324impl Default for GlyphSet {
325 fn default() -> Self {
326 Self::legacy()
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct ScrollBar {
451 orientation: ScrollBarOrientation,
452 content_len: usize,
453 viewport_len: usize,
454 offset: usize,
455 track_style: Style,
456 thumb_style: Style,
457 glyph_set: GlyphSet,
458 arrows: ScrollBarArrows,
459 track_click_behavior: TrackClickBehavior,
460 scroll_step: usize,
461}
462
463impl ScrollBar {
464 pub fn new(orientation: ScrollBarOrientation, lengths: crate::ScrollLengths) -> Self {
478 Self {
479 orientation,
480 content_len: lengths.content_len,
481 viewport_len: lengths.viewport_len,
482 offset: 0,
483 track_style: Style::default(),
484 thumb_style: Style::default(),
485 glyph_set: GlyphSet::default(),
486 arrows: ScrollBarArrows::default(),
487 track_click_behavior: TrackClickBehavior::Page,
488 scroll_step: 1,
489 }
490 }
491
492 pub fn vertical(lengths: crate::ScrollLengths) -> Self {
494 Self::new(ScrollBarOrientation::Vertical, lengths)
495 }
496
497 pub fn horizontal(lengths: crate::ScrollLengths) -> Self {
499 Self::new(ScrollBarOrientation::Horizontal, lengths)
500 }
501
502 pub const fn orientation(mut self, orientation: ScrollBarOrientation) -> Self {
504 self.orientation = orientation;
505 self
506 }
507
508 pub const fn content_len(mut self, content_len: usize) -> Self {
514 self.content_len = content_len;
515 self
516 }
517
518 pub const fn viewport_len(mut self, viewport_len: usize) -> Self {
524 self.viewport_len = viewport_len;
525 self
526 }
527
528 pub const fn offset(mut self, offset: usize) -> Self {
532 self.offset = offset;
533 self
534 }
535
536 pub const fn track_style(mut self, style: Style) -> Self {
540 self.track_style = style;
541 self
542 }
543
544 pub const fn thumb_style(mut self, style: Style) -> Self {
548 self.thumb_style = style;
549 self
550 }
551
552 pub const fn glyph_set(mut self, glyph_set: GlyphSet) -> Self {
557 self.glyph_set = glyph_set;
558 self
559 }
560
561 pub const fn arrows(mut self, arrows: ScrollBarArrows) -> Self {
563 self.arrows = arrows;
564 self
565 }
566
567 pub const fn track_click_behavior(mut self, behavior: TrackClickBehavior) -> Self {
572 self.track_click_behavior = behavior;
573 self
574 }
575
576 pub fn scroll_step(mut self, step: usize) -> Self {
580 self.scroll_step = step.max(1);
581 self
582 }
583
584 pub fn handle_event(
613 &self,
614 area: Rect,
615 event: ScrollEvent,
616 interaction: &mut ScrollBarInteraction,
617 ) -> Option<ScrollCommand> {
618 if area.width == 0 || area.height == 0 {
619 return None;
620 }
621
622 let layout = self.arrow_layout(area);
623 let lengths = crate::ScrollLengths {
624 content_len: self.content_len,
625 viewport_len: self.viewport_len,
626 };
627 let track_cells = match self.orientation {
628 ScrollBarOrientation::Vertical => layout.track_area.height,
629 ScrollBarOrientation::Horizontal => layout.track_area.width,
630 };
631 let metrics = ScrollMetrics::new(lengths, self.offset, track_cells);
632
633 match event {
634 ScrollEvent::Pointer(event) => {
635 if let Some(command) =
636 self.handle_arrow_pointer(&layout, metrics, event, interaction)
637 {
638 return Some(command);
639 }
640 self.handle_pointer_event(layout.track_area, metrics, event, interaction)
641 }
642 ScrollEvent::ScrollWheel(event) => self.handle_scroll_wheel(area, metrics, event),
643 }
644 }
645
646 #[cfg(feature = "crossterm")]
647 pub fn handle_mouse_event(
652 &self,
653 area: Rect,
654 event: MouseEvent,
655 interaction: &mut ScrollBarInteraction,
656 ) -> Option<ScrollCommand> {
657 let event = match event.kind {
658 MouseEventKind::Down(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
659 column: event.column,
660 row: event.row,
661 kind: PointerEventKind::Down,
662 button: PointerButton::Primary,
663 })),
664 MouseEventKind::Up(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
665 column: event.column,
666 row: event.row,
667 kind: PointerEventKind::Up,
668 button: PointerButton::Primary,
669 })),
670 MouseEventKind::Drag(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
671 column: event.column,
672 row: event.row,
673 kind: PointerEventKind::Drag,
674 button: PointerButton::Primary,
675 })),
676 MouseEventKind::ScrollUp => Some(ScrollEvent::ScrollWheel(ScrollWheel {
677 axis: ScrollAxis::Vertical,
678 delta: -1,
679 column: event.column,
680 row: event.row,
681 })),
682 MouseEventKind::ScrollDown => Some(ScrollEvent::ScrollWheel(ScrollWheel {
683 axis: ScrollAxis::Vertical,
684 delta: 1,
685 column: event.column,
686 row: event.row,
687 })),
688 MouseEventKind::ScrollLeft => Some(ScrollEvent::ScrollWheel(ScrollWheel {
689 axis: ScrollAxis::Horizontal,
690 delta: -1,
691 column: event.column,
692 row: event.row,
693 })),
694 MouseEventKind::ScrollRight => Some(ScrollEvent::ScrollWheel(ScrollWheel {
695 axis: ScrollAxis::Horizontal,
696 delta: 1,
697 column: event.column,
698 row: event.row,
699 })),
700 _ => None,
701 };
702
703 event.and_then(|event| self.handle_event(area, event, interaction))
704 }
705
706 fn glyph_for_vertical(&self, fill: CellFill) -> (char, Style) {
707 match fill {
708 CellFill::Empty => (self.glyph_set.track_vertical, self.track_style),
709 CellFill::Full => (self.glyph_set.thumb_vertical_lower[7], self.thumb_style),
710 CellFill::Partial { start, len } => {
711 let index = len.saturating_sub(1) as usize;
712 let glyph = if start == 0 {
713 self.glyph_set.thumb_vertical_upper[index]
714 } else {
715 self.glyph_set.thumb_vertical_lower[index]
716 };
717 (glyph, self.thumb_style)
718 }
719 }
720 }
721
722 fn glyph_for_horizontal(&self, fill: CellFill) -> (char, Style) {
723 match fill {
724 CellFill::Empty => (self.glyph_set.track_horizontal, self.track_style),
725 CellFill::Full => (self.glyph_set.thumb_horizontal_left[7], self.thumb_style),
726 CellFill::Partial { start, len } => {
727 let index = len.saturating_sub(1) as usize;
728 let glyph = if start == 0 {
729 self.glyph_set.thumb_horizontal_left[index]
730 } else {
731 self.glyph_set.thumb_horizontal_right[index]
732 };
733 (glyph, self.thumb_style)
734 }
735 }
736 }
737
738 fn handle_pointer_event(
739 &self,
740 area: Rect,
741 metrics: ScrollMetrics,
742 event: PointerEvent,
743 interaction: &mut ScrollBarInteraction,
744 ) -> Option<ScrollCommand> {
745 if event.button != PointerButton::Primary {
746 return None;
747 }
748
749 match event.kind {
750 PointerEventKind::Down => {
751 let cell_index = axis_cell_index(area, event.column, event.row, self.orientation)?;
752 let position = cell_index
753 .saturating_mul(SUBCELL)
754 .saturating_add(SUBCELL / 2);
755 if metrics.thumb_len() == 0 {
756 return None;
757 }
758 match metrics.hit_test(position) {
759 HitTest::Thumb => {
760 let grab_offset = position.saturating_sub(metrics.thumb_start());
761 interaction.start_drag(grab_offset);
762 None
763 }
764 HitTest::Track => {
765 interaction.stop_drag();
766 self.handle_track_click(metrics, position)
767 }
768 }
769 }
770 PointerEventKind::Drag => match interaction.drag_state {
771 DragState::Idle => None,
772 DragState::Dragging { grab_offset } => {
773 let cell_index =
774 axis_cell_index_clamped(area, event.column, event.row, self.orientation)?;
775 let position = cell_index
776 .saturating_mul(SUBCELL)
777 .saturating_add(SUBCELL / 2);
778 let thumb_start = position.saturating_sub(grab_offset);
779 Some(ScrollCommand::SetOffset(
780 metrics.offset_for_thumb_start(thumb_start),
781 ))
782 }
783 },
784 PointerEventKind::Up => {
785 interaction.stop_drag();
786 None
787 }
788 }
789 }
790
791 fn handle_scroll_wheel(
792 &self,
793 area: Rect,
794 metrics: ScrollMetrics,
795 event: ScrollWheel,
796 ) -> Option<ScrollCommand> {
797 if !area.contains((event.column, event.row).into()) {
798 return None;
799 }
800
801 let matches_axis = match (self.orientation, event.axis) {
802 (ScrollBarOrientation::Vertical, ScrollAxis::Vertical) => true,
803 (ScrollBarOrientation::Horizontal, ScrollAxis::Horizontal) => true,
804 _ => false,
805 };
806
807 if !matches_axis {
808 return None;
809 }
810
811 let step = self.scroll_step.max(1) as isize;
812 let delta = event.delta.saturating_mul(step);
813 let max_offset = metrics.max_offset() as isize;
814 let next = (metrics.offset() as isize).saturating_add(delta);
815 let next = next.clamp(0, max_offset);
816 Some(ScrollCommand::SetOffset(next as usize))
817 }
818
819 fn handle_track_click(&self, metrics: ScrollMetrics, position: usize) -> Option<ScrollCommand> {
820 if metrics.max_offset() == 0 {
821 return None;
822 }
823
824 match self.track_click_behavior {
825 TrackClickBehavior::Page => {
826 let thumb_end = metrics.thumb_start().saturating_add(metrics.thumb_len());
827 if position < metrics.thumb_start() {
828 Some(ScrollCommand::SetOffset(
829 metrics.offset().saturating_sub(metrics.viewport_len()),
830 ))
831 } else if position >= thumb_end {
832 Some(ScrollCommand::SetOffset(
833 (metrics.offset() + metrics.viewport_len()).min(metrics.max_offset()),
834 ))
835 } else {
836 None
837 }
838 }
839 TrackClickBehavior::JumpToClick => {
840 let half_thumb = metrics.thumb_len() / 2;
841 let thumb_start = position.saturating_sub(half_thumb);
842 Some(ScrollCommand::SetOffset(
843 metrics.offset_for_thumb_start(thumb_start),
844 ))
845 }
846 }
847 }
848
849 fn arrow_layout(&self, area: Rect) -> ArrowLayout {
850 let mut track_area = area;
851 let (start, end) = match self.orientation {
852 ScrollBarOrientation::Vertical => {
853 let start_enabled = self.arrows.has_start() && area.height > 0;
854 let end_enabled = self.arrows.has_end() && area.height > start_enabled as u16;
855 let start = start_enabled.then_some((area.x, area.y));
856 let end = end_enabled
857 .then_some((area.x, area.y.saturating_add(area.height).saturating_sub(1)));
858 if start_enabled {
859 track_area.y = track_area.y.saturating_add(1);
860 track_area.height = track_area.height.saturating_sub(1);
861 }
862 if end_enabled {
863 track_area.height = track_area.height.saturating_sub(1);
864 }
865 (start, end)
866 }
867 ScrollBarOrientation::Horizontal => {
868 let start_enabled = self.arrows.has_start() && area.width > 0;
869 let end_enabled = self.arrows.has_end() && area.width > start_enabled as u16;
870 let start = start_enabled.then_some((area.x, area.y));
871 let end = end_enabled
872 .then_some((area.x.saturating_add(area.width).saturating_sub(1), area.y));
873 if start_enabled {
874 track_area.x = track_area.x.saturating_add(1);
875 track_area.width = track_area.width.saturating_sub(1);
876 }
877 if end_enabled {
878 track_area.width = track_area.width.saturating_sub(1);
879 }
880 (start, end)
881 }
882 };
883
884 ArrowLayout {
885 track_area,
886 start,
887 end,
888 }
889 }
890
891 fn arrow_hit(&self, layout: &ArrowLayout, event: PointerEvent) -> Option<ArrowHit> {
892 if let Some((x, y)) = layout.start {
893 if event.column == x && event.row == y {
894 return Some(ArrowHit::Start);
895 }
896 }
897 if let Some((x, y)) = layout.end {
898 if event.column == x && event.row == y {
899 return Some(ArrowHit::End);
900 }
901 }
902 None
903 }
904
905 fn handle_arrow_pointer(
906 &self,
907 layout: &ArrowLayout,
908 metrics: ScrollMetrics,
909 event: PointerEvent,
910 interaction: &mut ScrollBarInteraction,
911 ) -> Option<ScrollCommand> {
912 if event.button != PointerButton::Primary || event.kind != PointerEventKind::Down {
913 return None;
914 }
915
916 let hit = self.arrow_hit(layout, event)?;
917 if metrics.max_offset() == 0 {
918 return None;
919 }
920
921 interaction.stop_drag();
922 let step = self.scroll_step.max(1) as isize;
923 let delta = match hit {
924 ArrowHit::Start => -step,
925 ArrowHit::End => step,
926 };
927 let max_offset = metrics.max_offset() as isize;
928 let next = (metrics.offset() as isize).saturating_add(delta);
929 let next = next.clamp(0, max_offset);
930 Some(ScrollCommand::SetOffset(next as usize))
931 }
932
933 fn render_arrows(&self, layout: &ArrowLayout, buf: &mut Buffer) {
934 if let Some((x, y)) = layout.start {
935 let glyph = match self.orientation {
936 ScrollBarOrientation::Vertical => self.glyph_set.arrow_vertical_start,
937 ScrollBarOrientation::Horizontal => self.glyph_set.arrow_horizontal_start,
938 };
939 let cell = &mut buf[(x, y)];
940 cell.set_char(glyph);
941 cell.set_style(self.track_style);
942 }
943 if let Some((x, y)) = layout.end {
944 let glyph = match self.orientation {
945 ScrollBarOrientation::Vertical => self.glyph_set.arrow_vertical_end,
946 ScrollBarOrientation::Horizontal => self.glyph_set.arrow_horizontal_end,
947 };
948 let cell = &mut buf[(x, y)];
949 cell.set_char(glyph);
950 cell.set_style(self.track_style);
951 }
952 }
953}
954
955fn axis_cell_index(
956 area: Rect,
957 column: u16,
958 row: u16,
959 orientation: ScrollBarOrientation,
960) -> Option<usize> {
961 match orientation {
962 ScrollBarOrientation::Vertical => {
963 if row < area.y || row >= area.y.saturating_add(area.height) {
964 None
965 } else {
966 Some(row.saturating_sub(area.y) as usize)
967 }
968 }
969 ScrollBarOrientation::Horizontal => {
970 if column < area.x || column >= area.x.saturating_add(area.width) {
971 None
972 } else {
973 Some(column.saturating_sub(area.x) as usize)
974 }
975 }
976 }
977}
978
979fn axis_cell_index_clamped(
980 area: Rect,
981 column: u16,
982 row: u16,
983 orientation: ScrollBarOrientation,
984) -> Option<usize> {
985 match orientation {
986 ScrollBarOrientation::Vertical => {
987 if area.height == 0 {
988 return None;
989 }
990 let end = area.y.saturating_add(area.height).saturating_sub(1);
991 let row = row.clamp(area.y, end);
992 Some(row.saturating_sub(area.y) as usize)
993 }
994 ScrollBarOrientation::Horizontal => {
995 if area.width == 0 {
996 return None;
997 }
998 let end = area.x.saturating_add(area.width).saturating_sub(1);
999 let column = column.clamp(area.x, end);
1000 Some(column.saturating_sub(area.x) as usize)
1001 }
1002 }
1003}
1004
1005impl Widget for &ScrollBar {
1006 fn render(self, area: Rect, buf: &mut Buffer) {
1007 if area.width == 0 || area.height == 0 {
1008 return;
1009 }
1010
1011 let layout = self.arrow_layout(area);
1012 self.render_arrows(&layout, buf);
1013 if layout.track_area.width == 0 || layout.track_area.height == 0 {
1014 return;
1015 }
1016
1017 match self.orientation {
1018 ScrollBarOrientation::Vertical => {
1019 let metrics = ScrollMetrics::new(
1020 crate::ScrollLengths {
1021 content_len: self.content_len,
1022 viewport_len: self.viewport_len,
1023 },
1024 self.offset,
1025 layout.track_area.height,
1026 );
1027 let x = layout.track_area.x;
1028 for (idx, y) in (layout.track_area.y
1029 ..layout.track_area.y.saturating_add(layout.track_area.height))
1030 .enumerate()
1031 {
1032 let (glyph, style) = self.glyph_for_vertical(metrics.cell_fill(idx));
1033 let cell = &mut buf[(x, y)];
1034 cell.set_char(glyph);
1035 cell.set_style(style);
1036 }
1037 }
1038 ScrollBarOrientation::Horizontal => {
1039 let metrics = ScrollMetrics::new(
1040 crate::ScrollLengths {
1041 content_len: self.content_len,
1042 viewport_len: self.viewport_len,
1043 },
1044 self.offset,
1045 layout.track_area.width,
1046 );
1047 let y = layout.track_area.y;
1048 for (idx, x) in (layout.track_area.x
1049 ..layout.track_area.x.saturating_add(layout.track_area.width))
1050 .enumerate()
1051 {
1052 let (glyph, style) = self.glyph_for_horizontal(metrics.cell_fill(idx));
1053 let cell = &mut buf[(x, y)];
1054 cell.set_char(glyph);
1055 cell.set_style(style);
1056 }
1057 }
1058 }
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use ratatui_core::buffer::Buffer;
1065 use ratatui_core::layout::Rect;
1066
1067 use super::*;
1068 use crate::ScrollLengths;
1069 #[test]
1070 fn render_vertical_fractional_thumb() {
1071 let scrollbar = ScrollBar::vertical(ScrollLengths {
1072 content_len: 10,
1073 viewport_len: 3,
1074 })
1075 .arrows(ScrollBarArrows::None)
1076 .offset(1);
1077 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 4));
1078 (&scrollbar).render(buf.area, &mut buf);
1079 assert_eq!(buf, Buffer::with_lines(vec!["▅", "▀", "│", "│"]));
1080 }
1081
1082 #[test]
1083 fn render_horizontal_fractional_thumb() {
1084 let scrollbar = ScrollBar::horizontal(ScrollLengths {
1085 content_len: 10,
1086 viewport_len: 3,
1087 })
1088 .arrows(ScrollBarArrows::None)
1089 .offset(1);
1090 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1091 (&scrollbar).render(buf.area, &mut buf);
1092 assert_eq!(buf, Buffer::with_lines(vec!["🮉▌──"]));
1093 }
1094
1095 #[test]
1096 fn render_full_thumb_when_no_scroll() {
1097 let scrollbar = ScrollBar::vertical(ScrollLengths {
1098 content_len: 5,
1099 viewport_len: 10,
1100 })
1101 .arrows(ScrollBarArrows::None);
1102 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 3));
1103 (&scrollbar).render(buf.area, &mut buf);
1104 assert_eq!(buf, Buffer::with_lines(vec!["█", "█", "█"]));
1105 }
1106
1107 #[test]
1108 fn render_vertical_arrows() {
1109 let scrollbar = ScrollBar::vertical(ScrollLengths {
1110 content_len: 5,
1111 viewport_len: 2,
1112 });
1113 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 3));
1114 (&scrollbar).render(buf.area, &mut buf);
1115 assert_eq!(buf, Buffer::with_lines(vec!["▲", "█", "▼"]));
1116 }
1117
1118 #[test]
1119 fn render_horizontal_arrows() {
1120 let scrollbar = ScrollBar::horizontal(ScrollLengths {
1121 content_len: 5,
1122 viewport_len: 2,
1123 });
1124 let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
1125 (&scrollbar).render(buf.area, &mut buf);
1126 assert_eq!(buf, Buffer::with_lines(vec!["◄█►"]));
1127 }
1128
1129 #[test]
1130 fn handle_track_click_pages() {
1131 let scrollbar = ScrollBar::vertical(ScrollLengths {
1132 content_len: 100,
1133 viewport_len: 20,
1134 })
1135 .arrows(ScrollBarArrows::None)
1136 .offset(40);
1137 let area = Rect::new(0, 0, 1, 10);
1138 let mut interaction = ScrollBarInteraction::default();
1139 let event = ScrollEvent::Pointer(PointerEvent {
1140 column: 0,
1141 row: 0,
1142 kind: PointerEventKind::Down,
1143 button: PointerButton::Primary,
1144 });
1145 let metrics = ScrollMetrics::new(
1146 ScrollLengths {
1147 content_len: 100,
1148 viewport_len: 20,
1149 },
1150 40,
1151 area.height,
1152 );
1153 let expected = metrics.offset().saturating_sub(metrics.viewport_len());
1154 assert_eq!(
1155 scrollbar.handle_event(area, event, &mut interaction),
1156 Some(ScrollCommand::SetOffset(expected))
1157 );
1158 }
1159
1160 #[test]
1161 fn handle_drag_updates_offset() {
1162 let scrollbar = ScrollBar::vertical(ScrollLengths {
1163 content_len: 100,
1164 viewport_len: 20,
1165 })
1166 .arrows(ScrollBarArrows::None)
1167 .offset(40);
1168 let area = Rect::new(0, 0, 1, 10);
1169 let metrics = ScrollMetrics::new(
1170 ScrollLengths {
1171 content_len: 100,
1172 viewport_len: 20,
1173 },
1174 40,
1175 area.height,
1176 );
1177 let mut interaction = ScrollBarInteraction::default();
1178 let down = ScrollEvent::Pointer(PointerEvent {
1179 column: 0,
1180 row: 4,
1181 kind: PointerEventKind::Down,
1182 button: PointerButton::Primary,
1183 });
1184 assert_eq!(scrollbar.handle_event(area, down, &mut interaction), None);
1185
1186 let drag = ScrollEvent::Pointer(PointerEvent {
1187 column: 0,
1188 row: 6,
1189 kind: PointerEventKind::Drag,
1190 button: PointerButton::Primary,
1191 });
1192 let grab_offset = (4 * SUBCELL + SUBCELL / 2).saturating_sub(metrics.thumb_start());
1193 let expected_thumb_start = (6 * SUBCELL + SUBCELL / 2).saturating_sub(grab_offset);
1194 let expected = metrics.offset_for_thumb_start(expected_thumb_start);
1195 assert_eq!(
1196 scrollbar.handle_event(area, drag, &mut interaction),
1197 Some(ScrollCommand::SetOffset(expected))
1198 );
1199 }
1200
1201 #[test]
1202 fn handle_scroll_wheel_uses_step() {
1203 let scrollbar = ScrollBar::vertical(ScrollLengths {
1204 content_len: 100,
1205 viewport_len: 20,
1206 })
1207 .arrows(ScrollBarArrows::None)
1208 .offset(40)
1209 .scroll_step(3);
1210 let area = Rect::new(0, 0, 1, 10);
1211 let mut interaction = ScrollBarInteraction::default();
1212 let event = ScrollEvent::ScrollWheel(ScrollWheel {
1213 axis: ScrollAxis::Vertical,
1214 delta: 1,
1215 column: 0,
1216 row: 0,
1217 });
1218 assert_eq!(
1219 scrollbar.handle_event(area, event, &mut interaction),
1220 Some(ScrollCommand::SetOffset(43))
1221 );
1222 }
1223
1224 #[test]
1225 fn handle_arrow_click_steps_offset() {
1226 let scrollbar = ScrollBar::vertical(ScrollLengths {
1227 content_len: 100,
1228 viewport_len: 20,
1229 })
1230 .offset(10)
1231 .scroll_step(5);
1232 let area = Rect::new(0, 0, 1, 5);
1233 let mut interaction = ScrollBarInteraction::default();
1234 let up = ScrollEvent::Pointer(PointerEvent {
1235 column: 0,
1236 row: 0,
1237 kind: PointerEventKind::Down,
1238 button: PointerButton::Primary,
1239 });
1240 assert_eq!(
1241 scrollbar.handle_event(area, up, &mut interaction),
1242 Some(ScrollCommand::SetOffset(5))
1243 );
1244
1245 let down = ScrollEvent::Pointer(PointerEvent {
1246 column: 0,
1247 row: 4,
1248 kind: PointerEventKind::Down,
1249 button: PointerButton::Primary,
1250 });
1251 assert_eq!(
1252 scrollbar.handle_event(area, down, &mut interaction),
1253 Some(ScrollCommand::SetOffset(15))
1254 );
1255 }
1256}