1#![warn(clippy::pedantic)]
3#![allow(
4 clippy::cast_possible_truncation,
5 clippy::cast_precision_loss,
6 clippy::cast_sign_loss,
7 clippy::module_name_repetitions
8)]
9
10use core::iter;
11
12use ratatui_core::buffer::{Buffer, CellWidth};
13use ratatui_core::layout::Rect;
14use ratatui_core::style::Style;
15use ratatui_core::symbols::scrollbar::{DOUBLE_HORIZONTAL, DOUBLE_VERTICAL, Set};
16use ratatui_core::widgets::StatefulWidget;
17use strum::{Display, EnumString};
18
19#[derive(Debug, Clone, Eq, PartialEq, Hash)]
83pub struct Scrollbar<'a> {
84 orientation: ScrollbarOrientation,
85 thumb_style: Style,
86 thumb_symbol: &'a str,
87 track_style: Style,
88 track_symbol: Option<&'a str>,
89 begin_symbol: Option<&'a str>,
90 begin_style: Style,
91 end_symbol: Option<&'a str>,
92 end_style: Style,
93}
94
95#[derive(Debug, Default, Display, EnumString, Clone, Eq, PartialEq, Hash)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub enum ScrollbarOrientation {
107 #[default]
109 VerticalRight,
110 VerticalLeft,
112 HorizontalBottom,
114 HorizontalTop,
116}
117
118#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
144#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
145pub struct ScrollbarState {
146 content_length: usize,
148 position: usize,
150 viewport_content_length: usize,
154}
155
156#[derive(Debug, Default, Display, EnumString, Clone, Copy, Eq, PartialEq, Hash)]
162#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
163pub enum ScrollDirection {
164 #[default]
166 Forward,
167 Backward,
169}
170
171impl Default for Scrollbar<'_> {
172 fn default() -> Self {
173 Self::new(ScrollbarOrientation::default())
174 }
175}
176
177impl<'a> Scrollbar<'a> {
178 #[must_use = "creates the Scrollbar"]
183 pub const fn new(orientation: ScrollbarOrientation) -> Self {
184 let symbols = if orientation.is_vertical() {
185 DOUBLE_VERTICAL
186 } else {
187 DOUBLE_HORIZONTAL
188 };
189 Self::new_with_symbols(orientation, &symbols)
190 }
191
192 #[must_use = "creates the Scrollbar"]
194 const fn new_with_symbols(orientation: ScrollbarOrientation, symbols: &Set<'a>) -> Self {
195 Self {
196 orientation,
197 thumb_symbol: symbols.thumb,
198 thumb_style: Style::new(),
199 track_symbol: Some(symbols.track),
200 track_style: Style::new(),
201 begin_symbol: Some(symbols.begin),
202 begin_style: Style::new(),
203 end_symbol: Some(symbols.end),
204 end_style: Style::new(),
205 }
206 }
207
208 #[must_use = "method moves the value of self and returns the modified value"]
217 pub const fn orientation(mut self, orientation: ScrollbarOrientation) -> Self {
218 self.orientation = orientation;
219 let symbols = if self.orientation.is_vertical() {
220 DOUBLE_VERTICAL
221 } else {
222 DOUBLE_HORIZONTAL
223 };
224 self.symbols(symbols)
225 }
226
227 #[must_use = "method moves the value of self and returns the modified value"]
234 pub const fn orientation_and_symbol(
235 mut self,
236 orientation: ScrollbarOrientation,
237 symbols: Set<'a>,
238 ) -> Self {
239 self.orientation = orientation;
240 self.symbols(symbols)
241 }
242
243 #[must_use = "method moves the value of self and returns the modified value"]
250 pub const fn thumb_symbol(mut self, thumb_symbol: &'a str) -> Self {
251 self.thumb_symbol = thumb_symbol;
252 self
253 }
254
255 #[must_use = "method moves the value of self and returns the modified value"]
267 pub fn thumb_style<S: Into<Style>>(mut self, thumb_style: S) -> Self {
268 self.thumb_style = thumb_style.into();
269 self
270 }
271
272 #[must_use = "method moves the value of self and returns the modified value"]
278 pub const fn track_symbol(mut self, track_symbol: Option<&'a str>) -> Self {
279 self.track_symbol = track_symbol;
280 self
281 }
282
283 #[must_use = "method moves the value of self and returns the modified value"]
294 pub fn track_style<S: Into<Style>>(mut self, track_style: S) -> Self {
295 self.track_style = track_style.into();
296 self
297 }
298
299 #[must_use = "method moves the value of self and returns the modified value"]
305 pub const fn begin_symbol(mut self, begin_symbol: Option<&'a str>) -> Self {
306 self.begin_symbol = begin_symbol;
307 self
308 }
309
310 #[must_use = "method moves the value of self and returns the modified value"]
321 pub fn begin_style<S: Into<Style>>(mut self, begin_style: S) -> Self {
322 self.begin_style = begin_style.into();
323 self
324 }
325
326 #[must_use = "method moves the value of self and returns the modified value"]
332 pub const fn end_symbol(mut self, end_symbol: Option<&'a str>) -> Self {
333 self.end_symbol = end_symbol;
334 self
335 }
336
337 #[must_use = "method moves the value of self and returns the modified value"]
348 pub fn end_style<S: Into<Style>>(mut self, end_style: S) -> Self {
349 self.end_style = end_style.into();
350 self
351 }
352
353 #[expect(clippy::needless_pass_by_value)] #[must_use = "method moves the value of self and returns the modified value"]
371 pub const fn symbols(mut self, symbols: Set<'a>) -> Self {
372 self.thumb_symbol = symbols.thumb;
373 if self.track_symbol.is_some() {
374 self.track_symbol = Some(symbols.track);
375 }
376 if self.begin_symbol.is_some() {
377 self.begin_symbol = Some(symbols.begin);
378 }
379 if self.end_symbol.is_some() {
380 self.end_symbol = Some(symbols.end);
381 }
382 self
383 }
384
385 #[must_use = "method moves the value of self and returns the modified value"]
403 pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
404 let style = style.into();
405 self.track_style = style;
406 self.thumb_style = style;
407 self.begin_style = style;
408 self.end_style = style;
409 self
410 }
411}
412
413impl ScrollbarState {
414 #[must_use = "creates the ScrollbarState"]
419 pub const fn new(content_length: usize) -> Self {
420 Self {
421 content_length,
422 position: 0,
423 viewport_content_length: 0,
424 }
425 }
426
427 #[must_use = "method moves the value of self and returns the modified value"]
433 pub const fn position(mut self, position: usize) -> Self {
434 self.position = position;
435 self
436 }
437
438 #[must_use = "method moves the value of self and returns the modified value"]
445 pub const fn content_length(mut self, content_length: usize) -> Self {
446 self.content_length = content_length;
447 self
448 }
449
450 #[must_use = "method moves the value of self and returns the modified value"]
454 pub const fn viewport_content_length(mut self, viewport_content_length: usize) -> Self {
455 self.viewport_content_length = viewport_content_length;
456 self
457 }
458
459 pub const fn prev(&mut self) {
461 self.position = self.position.saturating_sub(1);
462 }
463
464 pub fn next(&mut self) {
466 self.position = self
467 .position
468 .saturating_add(1)
469 .min(self.content_length.saturating_sub(1));
470 }
471
472 pub const fn first(&mut self) {
474 self.position = 0;
475 }
476
477 pub const fn last(&mut self) {
479 self.position = self.content_length.saturating_sub(1);
480 }
481
482 pub fn scroll(&mut self, direction: ScrollDirection) {
484 match direction {
485 ScrollDirection::Forward => {
486 self.next();
487 }
488 ScrollDirection::Backward => {
489 self.prev();
490 }
491 }
492 }
493
494 #[must_use = "returns the current position within the scrollable content"]
496 pub const fn get_position(&self) -> usize {
497 self.position
498 }
499}
500
501impl StatefulWidget for Scrollbar<'_> {
502 type State = ScrollbarState;
503
504 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
505 if state.content_length == 0 || self.track_length_excluding_arrow_heads(area) == 0 {
506 return;
507 }
508
509 if let Some(area) = self.scrollbar_area(area) {
510 let areas = area.columns().flat_map(Rect::rows);
511 let bar_symbols = self.bar_symbols(area, state);
512 for (area, bar) in areas.zip(bar_symbols) {
513 if let Some((symbol, style)) = bar {
514 buf.set_string(area.x, area.y, symbol, style);
515 }
516 }
517 }
518 }
519}
520
521impl Scrollbar<'_> {
522 fn bar_symbols(
524 &self,
525 area: Rect,
526 state: &ScrollbarState,
527 ) -> impl Iterator<Item = Option<(&str, Style)>> {
528 let (track_start_len, thumb_len, track_end_len) = self.part_lengths(area, state);
529
530 let begin = self.begin_symbol.map(|s| Some((s, self.begin_style)));
531 let track = Some(self.track_symbol.map(|s| (s, self.track_style)));
532 let thumb = Some(Some((self.thumb_symbol, self.thumb_style)));
533 let end = self.end_symbol.map(|s| Some((s, self.end_style)));
534
535 iter::once(begin)
537 .chain(iter::repeat_n(track, track_start_len))
539 .chain(iter::repeat_n(thumb, thumb_len))
541 .chain(iter::repeat_n(track, track_end_len))
543 .chain(iter::once(end))
545 .flatten()
546 }
547
548 fn part_lengths(&self, area: Rect, state: &ScrollbarState) -> (usize, usize, usize) {
558 #[inline]
561 const fn rounding_divide(numerator: usize, denominator: usize) -> usize {
562 (numerator + denominator / 2) / denominator
563 }
564
565 let track_length = self.track_length_excluding_arrow_heads(area) as usize;
566
567 if track_length == 0 {
568 return (0, 0, 0);
569 }
570
571 let viewport_length = self.viewport_length(state, area);
572
573 let max_position = state.content_length.saturating_sub(1);
574 let start_position = state.position.clamp(0, max_position);
575 let max_viewport_position = max_position.saturating_add(viewport_length);
576
577 if max_viewport_position == 0 {
578 return (0, track_length, 0);
580 }
581
582 let thumb_length = rounding_divide(
583 viewport_length.saturating_mul(track_length),
584 max_viewport_position,
585 )
586 .clamp(1, track_length);
587
588 let thumb_start = rounding_divide(
592 start_position.saturating_mul(track_length),
593 max_viewport_position,
594 )
595 .clamp(0, track_length.saturating_sub(thumb_length));
596
597 let track_end = track_length.saturating_sub(thumb_start + thumb_length);
598 (thumb_start, thumb_length, track_end)
599 }
600
601 fn scrollbar_area(&self, area: Rect) -> Option<Rect> {
602 match self.orientation {
603 ScrollbarOrientation::VerticalLeft => area.columns().next(),
604 ScrollbarOrientation::VerticalRight => area.columns().next_back(),
605 ScrollbarOrientation::HorizontalTop => area.rows().next(),
606 ScrollbarOrientation::HorizontalBottom => area.rows().next_back(),
607 }
608 }
609
610 fn track_length_excluding_arrow_heads(&self, area: Rect) -> u16 {
618 let start_len = self.begin_symbol.map_or(0, CellWidth::cell_width);
619 let end_len = self.end_symbol.map_or(0, CellWidth::cell_width);
620 let arrows_len = start_len.saturating_add(end_len);
621 if self.orientation.is_vertical() {
622 area.height.saturating_sub(arrows_len)
623 } else {
624 area.width.saturating_sub(arrows_len)
625 }
626 }
627
628 const fn viewport_length(&self, state: &ScrollbarState, area: Rect) -> usize {
629 if state.viewport_content_length != 0 {
630 state.viewport_content_length
631 } else if self.orientation.is_vertical() {
632 area.height as usize
633 } else {
634 area.width as usize
635 }
636 }
637}
638
639impl ScrollbarOrientation {
640 #[must_use = "returns the requested kind of the scrollbar"]
642 pub const fn is_vertical(&self) -> bool {
643 matches!(self, Self::VerticalRight | Self::VerticalLeft)
644 }
645
646 #[must_use = "returns the requested kind of the scrollbar"]
648 pub const fn is_horizontal(&self) -> bool {
649 matches!(self, Self::HorizontalBottom | Self::HorizontalTop)
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use alloc::format;
656 use alloc::string::ToString;
657 use core::str::FromStr;
658
659 use ratatui_core::text::Text;
660 use ratatui_core::widgets::Widget;
661 use rstest::{fixture, rstest};
662 use strum::ParseError;
663 use unicode_width::UnicodeWidthStr;
664
665 use super::*;
666
667 #[test]
668 fn scroll_direction_to_string() {
669 assert_eq!(ScrollDirection::Forward.to_string(), "Forward");
670 assert_eq!(ScrollDirection::Backward.to_string(), "Backward");
671 }
672
673 #[test]
674 fn scroll_direction_from_str() {
675 assert_eq!("Forward".parse(), Ok(ScrollDirection::Forward));
676 assert_eq!("Backward".parse(), Ok(ScrollDirection::Backward));
677 assert_eq!(
678 ScrollDirection::from_str(""),
679 Err(ParseError::VariantNotFound)
680 );
681 }
682
683 #[test]
684 fn scrollbar_orientation_to_string() {
685 use ScrollbarOrientation::*;
686 assert_eq!(VerticalRight.to_string(), "VerticalRight");
687 assert_eq!(VerticalLeft.to_string(), "VerticalLeft");
688 assert_eq!(HorizontalBottom.to_string(), "HorizontalBottom");
689 assert_eq!(HorizontalTop.to_string(), "HorizontalTop");
690 }
691
692 #[test]
693 fn scrollbar_orientation_from_str() {
694 use ScrollbarOrientation::*;
695 assert_eq!("VerticalRight".parse(), Ok(VerticalRight));
696 assert_eq!("VerticalLeft".parse(), Ok(VerticalLeft));
697 assert_eq!("HorizontalBottom".parse(), Ok(HorizontalBottom));
698 assert_eq!("HorizontalTop".parse(), Ok(HorizontalTop));
699 assert_eq!(
700 ScrollbarOrientation::from_str(""),
701 Err(ParseError::VariantNotFound)
702 );
703 }
704
705 #[fixture]
706 fn scrollbar_no_arrows() -> Scrollbar<'static> {
707 Scrollbar::new(ScrollbarOrientation::HorizontalTop)
708 .begin_symbol(None)
709 .end_symbol(None)
710 .track_symbol(Some("-"))
711 .thumb_symbol("#")
712 }
713
714 #[rstest]
715 #[case::area_2_position_0("#-", 0, 2)]
716 #[case::area_2_position_1("-#", 1, 2)]
717 fn render_scrollbar_simplest(
718 #[case] expected: &str,
719 #[case] position: usize,
720 #[case] content_length: usize,
721 scrollbar_no_arrows: Scrollbar,
722 ) {
723 let mut buffer = Buffer::empty(Rect::new(0, 0, expected.width() as u16, 1));
724 let mut state = ScrollbarState::new(content_length).position(position);
725 scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
726 assert_eq!(buffer, Buffer::with_lines([expected]));
727 }
728
729 #[rstest]
730 #[case::position_0("#####-----", 0, 10)]
731 #[case::position_1("-#####----", 1, 10)]
732 #[case::position_2("-#####----", 2, 10)]
733 #[case::position_3("--#####---", 3, 10)]
734 #[case::position_4("--#####---", 4, 10)]
735 #[case::position_5("---#####--", 5, 10)]
736 #[case::position_6("---#####--", 6, 10)]
737 #[case::position_7("----#####-", 7, 10)]
738 #[case::position_8("----#####-", 8, 10)]
739 #[case::position_9("-----#####", 9, 10)]
740 fn render_scrollbar_simple(
741 #[case] expected: &str,
742 #[case] position: usize,
743 #[case] content_length: usize,
744 scrollbar_no_arrows: Scrollbar,
745 ) {
746 let mut buffer = Buffer::empty(Rect::new(0, 0, expected.width() as u16, 1));
747 let mut state = ScrollbarState::new(content_length).position(position);
748 scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
749 assert_eq!(buffer, Buffer::with_lines([expected]));
750 }
751
752 #[rstest]
753 #[case::position_0(" ", 0, 0)]
754 fn render_scrollbar_nobar(
755 #[case] expected: &str,
756 #[case] position: usize,
757 #[case] content_length: usize,
758 scrollbar_no_arrows: Scrollbar,
759 ) {
760 let size = expected.width();
761 let mut buffer = Buffer::empty(Rect::new(0, 0, size as u16, 1));
762 let mut state = ScrollbarState::new(content_length).position(position);
763 scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
764 assert_eq!(buffer, Buffer::with_lines([expected]));
765 }
766
767 #[rstest]
768 #[case::fullbar_position_0("##########", 0, 1)]
769 #[case::almost_fullbar_position_0("#########-", 0, 2)]
770 #[case::almost_fullbar_position_1("-#########", 1, 2)]
771 fn render_scrollbar_fullbar(
772 #[case] expected: &str,
773 #[case] position: usize,
774 #[case] content_length: usize,
775 scrollbar_no_arrows: Scrollbar,
776 ) {
777 let size = expected.width();
778 let mut buffer = Buffer::empty(Rect::new(0, 0, size as u16, 1));
779 let mut state = ScrollbarState::new(content_length).position(position);
780 scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
781 assert_eq!(buffer, Buffer::with_lines([expected]));
782 }
783
784 #[rstest]
785 #[case::position_0("#########-", 0, 2)]
786 #[case::position_1("-#########", 1, 2)]
787 fn render_scrollbar_almost_fullbar(
788 #[case] expected: &str,
789 #[case] position: usize,
790 #[case] content_length: usize,
791 scrollbar_no_arrows: Scrollbar,
792 ) {
793 let size = expected.width();
794 let mut buffer = Buffer::empty(Rect::new(0, 0, size as u16, 1));
795 let mut state = ScrollbarState::new(content_length).position(position);
796 scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
797 assert_eq!(buffer, Buffer::with_lines([expected]));
798 }
799
800 #[rstest]
801 #[case::position_0("█████═════", 0, 10)]
802 #[case::position_1("═█████════", 1, 10)]
803 #[case::position_2("═█████════", 2, 10)]
804 #[case::position_3("══█████═══", 3, 10)]
805 #[case::position_4("══█████═══", 4, 10)]
806 #[case::position_5("═══█████══", 5, 10)]
807 #[case::position_6("═══█████══", 6, 10)]
808 #[case::position_7("════█████═", 7, 10)]
809 #[case::position_8("════█████═", 8, 10)]
810 #[case::position_9("═════█████", 9, 10)]
811 #[case::position_out_of_bounds("═════█████", 100, 10)]
812 fn render_scrollbar_without_symbols(
813 #[case] expected: &str,
814 #[case] position: usize,
815 #[case] content_length: usize,
816 ) {
817 let size = expected.width() as u16;
818 let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
819 let mut state = ScrollbarState::new(content_length).position(position);
820 Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
821 .begin_symbol(None)
822 .end_symbol(None)
823 .render(buffer.area, &mut buffer, &mut state);
824 assert_eq!(buffer, Buffer::with_lines([expected]));
825 }
826
827 #[rstest]
828 #[case::position_0("█████ ", 0, 10)]
829 #[case::position_1(" █████ ", 1, 10)]
830 #[case::position_2(" █████ ", 2, 10)]
831 #[case::position_3(" █████ ", 3, 10)]
832 #[case::position_4(" █████ ", 4, 10)]
833 #[case::position_5(" █████ ", 5, 10)]
834 #[case::position_6(" █████ ", 6, 10)]
835 #[case::position_7(" █████ ", 7, 10)]
836 #[case::position_8(" █████ ", 8, 10)]
837 #[case::position_9(" █████", 9, 10)]
838 #[case::position_out_of_bounds(" █████", 100, 10)]
839 fn render_scrollbar_without_track_symbols(
840 #[case] expected: &str,
841 #[case] position: usize,
842 #[case] content_length: usize,
843 ) {
844 let size = expected.width() as u16;
845 let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
846 let mut state = ScrollbarState::new(content_length).position(position);
847 Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
848 .track_symbol(None)
849 .begin_symbol(None)
850 .end_symbol(None)
851 .render(buffer.area, &mut buffer, &mut state);
852 assert_eq!(buffer, Buffer::with_lines([expected]));
853 }
854
855 #[rstest]
856 #[case::position_0("█████-----", 0, 10)]
857 #[case::position_1("-█████----", 1, 10)]
858 #[case::position_2("-█████----", 2, 10)]
859 #[case::position_3("--█████---", 3, 10)]
860 #[case::position_4("--█████---", 4, 10)]
861 #[case::position_5("---█████--", 5, 10)]
862 #[case::position_6("---█████--", 6, 10)]
863 #[case::position_7("----█████-", 7, 10)]
864 #[case::position_8("----█████-", 8, 10)]
865 #[case::position_9("-----█████", 9, 10)]
866 #[case::position_out_of_bounds("-----█████", 100, 10)]
867 fn render_scrollbar_without_track_symbols_over_content(
868 #[case] expected: &str,
869 #[case] position: usize,
870 #[case] content_length: usize,
871 ) {
872 let size = expected.width() as u16;
873 let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
874 let width = buffer.area.width as usize;
875 let s = "";
876 Text::from(format!("{s:-^width$}")).render(buffer.area, &mut buffer);
877 let mut state = ScrollbarState::new(content_length).position(position);
878 Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
879 .track_symbol(None)
880 .begin_symbol(None)
881 .end_symbol(None)
882 .render(buffer.area, &mut buffer, &mut state);
883 assert_eq!(buffer, Buffer::with_lines([expected]));
884 }
885
886 #[rstest]
887 #[case::position_0("<####---->", 0, 10)]
888 #[case::position_1("<####---->", 1, 10)]
889 #[case::position_2("<-####--->", 2, 10)]
890 #[case::position_3("<-####--->", 3, 10)]
891 #[case::position_4("<--####-->", 4, 10)]
892 #[case::position_5("<--####-->", 5, 10)]
893 #[case::position_6("<---####->", 6, 10)]
894 #[case::position_7("<---####->", 7, 10)]
895 #[case::position_8("<---####->", 8, 10)]
896 #[case::position_9("<----####>", 9, 10)]
897 #[case::position_one_out_of_bounds("<----####>", 10, 10)]
898 #[case::position_few_out_of_bounds("<----####>", 15, 10)]
899 #[case::position_very_many_out_of_bounds("<----####>", 500, 10)]
900 fn render_scrollbar_with_symbols(
901 #[case] expected: &str,
902 #[case] position: usize,
903 #[case] content_length: usize,
904 ) {
905 let size = expected.width() as u16;
906 let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
907 let mut state = ScrollbarState::new(content_length).position(position);
908 Scrollbar::new(ScrollbarOrientation::HorizontalTop)
909 .begin_symbol(Some("<"))
910 .end_symbol(Some(">"))
911 .track_symbol(Some("-"))
912 .thumb_symbol("#")
913 .render(buffer.area, &mut buffer, &mut state);
914 assert_eq!(buffer, Buffer::with_lines([expected]));
915 }
916
917 #[rstest]
918 #[case::position_0("█████═════", 0, 10)]
919 #[case::position_1("═█████════", 1, 10)]
920 #[case::position_2("═█████════", 2, 10)]
921 #[case::position_3("══█████═══", 3, 10)]
922 #[case::position_4("══█████═══", 4, 10)]
923 #[case::position_5("═══█████══", 5, 10)]
924 #[case::position_6("═══█████══", 6, 10)]
925 #[case::position_7("════█████═", 7, 10)]
926 #[case::position_8("════█████═", 8, 10)]
927 #[case::position_9("═════█████", 9, 10)]
928 #[case::position_out_of_bounds("═════█████", 100, 10)]
929 fn render_scrollbar_horizontal_bottom(
930 #[case] expected: &str,
931 #[case] position: usize,
932 #[case] content_length: usize,
933 ) {
934 let size = expected.width() as u16;
935 let mut buffer = Buffer::empty(Rect::new(0, 0, size, 2));
936 let mut state = ScrollbarState::new(content_length).position(position);
937 Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
938 .begin_symbol(None)
939 .end_symbol(None)
940 .render(buffer.area, &mut buffer, &mut state);
941 let empty_string = " ".repeat(size as usize);
942 assert_eq!(buffer, Buffer::with_lines([&empty_string, expected]));
943 }
944
945 #[rstest]
946 #[case::position_0("█████═════", 0, 10)]
947 #[case::position_1("═█████════", 1, 10)]
948 #[case::position_2("═█████════", 2, 10)]
949 #[case::position_3("══█████═══", 3, 10)]
950 #[case::position_4("══█████═══", 4, 10)]
951 #[case::position_5("═══█████══", 5, 10)]
952 #[case::position_6("═══█████══", 6, 10)]
953 #[case::position_7("════█████═", 7, 10)]
954 #[case::position_8("════█████═", 8, 10)]
955 #[case::position_9("═════█████", 9, 10)]
956 #[case::position_out_of_bounds("═════█████", 100, 10)]
957 fn render_scrollbar_horizontal_top(
958 #[case] expected: &str,
959 #[case] position: usize,
960 #[case] content_length: usize,
961 ) {
962 let size = expected.width() as u16;
963 let mut buffer = Buffer::empty(Rect::new(0, 0, size, 2));
964 let mut state = ScrollbarState::new(content_length).position(position);
965 Scrollbar::new(ScrollbarOrientation::HorizontalTop)
966 .begin_symbol(None)
967 .end_symbol(None)
968 .render(buffer.area, &mut buffer, &mut state);
969 let empty_string = " ".repeat(size as usize);
970 assert_eq!(buffer, Buffer::with_lines([expected, &empty_string]));
971 }
972
973 #[rstest]
974 #[case::position_0("<####---->", 0, 10)]
975 #[case::position_1("<####---->", 1, 10)]
976 #[case::position_2("<-####--->", 2, 10)]
977 #[case::position_3("<-####--->", 3, 10)]
978 #[case::position_4("<--####-->", 4, 10)]
979 #[case::position_5("<--####-->", 5, 10)]
980 #[case::position_6("<---####->", 6, 10)]
981 #[case::position_7("<---####->", 7, 10)]
982 #[case::position_8("<---####->", 8, 10)]
983 #[case::position_9("<----####>", 9, 10)]
984 #[case::position_one_out_of_bounds("<----####>", 10, 10)]
985 #[case::position_few_out_of_bounds("<----####>", 15, 10)]
986 #[case::position_very_many_out_of_bounds("<----####>", 500, 10)]
987 fn render_scrollbar_vertical_left(
988 #[case] expected: &str,
989 #[case] position: usize,
990 #[case] content_length: usize,
991 ) {
992 let size = expected.width() as u16;
993 let mut buffer = Buffer::empty(Rect::new(0, 0, 5, size));
994 let mut state = ScrollbarState::new(content_length).position(position);
995 Scrollbar::new(ScrollbarOrientation::VerticalLeft)
996 .begin_symbol(Some("<"))
997 .end_symbol(Some(">"))
998 .track_symbol(Some("-"))
999 .thumb_symbol("#")
1000 .render(buffer.area, &mut buffer, &mut state);
1001 let bar = expected.chars().map(|c| format!("{c} "));
1002 assert_eq!(buffer, Buffer::with_lines(bar));
1003 }
1004
1005 #[rstest]
1006 #[case::position_0("<####---->", 0, 10)]
1007 #[case::position_1("<####---->", 1, 10)]
1008 #[case::position_2("<-####--->", 2, 10)]
1009 #[case::position_3("<-####--->", 3, 10)]
1010 #[case::position_4("<--####-->", 4, 10)]
1011 #[case::position_5("<--####-->", 5, 10)]
1012 #[case::position_6("<---####->", 6, 10)]
1013 #[case::position_7("<---####->", 7, 10)]
1014 #[case::position_8("<---####->", 8, 10)]
1015 #[case::position_9("<----####>", 9, 10)]
1016 #[case::position_one_out_of_bounds("<----####>", 10, 10)]
1017 #[case::position_few_out_of_bounds("<----####>", 15, 10)]
1018 #[case::position_very_many_out_of_bounds("<----####>", 500, 10)]
1019 fn render_scrollbar_vertical_right(
1020 #[case] expected: &str,
1021 #[case] position: usize,
1022 #[case] content_length: usize,
1023 ) {
1024 let size = expected.width() as u16;
1025 let mut buffer = Buffer::empty(Rect::new(0, 0, 5, size));
1026 let mut state = ScrollbarState::new(content_length).position(position);
1027 Scrollbar::new(ScrollbarOrientation::VerticalRight)
1028 .begin_symbol(Some("<"))
1029 .end_symbol(Some(">"))
1030 .track_symbol(Some("-"))
1031 .thumb_symbol("#")
1032 .render(buffer.area, &mut buffer, &mut state);
1033 let bar = expected.chars().map(|c| format!(" {c}"));
1034 assert_eq!(buffer, Buffer::with_lines(bar));
1035 }
1036
1037 #[rstest]
1038 #[case::position_0("##--------", 0, 10)]
1039 #[case::position_1("-##-------", 1, 10)]
1040 #[case::position_2("--##------", 2, 10)]
1041 #[case::position_3("---##-----", 3, 10)]
1042 #[case::position_4("----##----", 4, 10)]
1043 #[case::position_5("-----##---", 5, 10)]
1044 #[case::position_6("-----##---", 6, 10)]
1045 #[case::position_7("------##--", 7, 10)]
1046 #[case::position_8("-------##-", 8, 10)]
1047 #[case::position_9("--------##", 9, 10)]
1048 #[case::position_one_out_of_bounds("--------##", 10, 10)]
1049 fn custom_viewport_length(
1050 #[case] expected: &str,
1051 #[case] position: usize,
1052 #[case] content_length: usize,
1053 scrollbar_no_arrows: Scrollbar,
1054 ) {
1055 let size = expected.width() as u16;
1056 let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
1057 let mut state = ScrollbarState::new(content_length)
1058 .position(position)
1059 .viewport_content_length(2);
1060 scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
1061 assert_eq!(buffer, Buffer::with_lines([expected]));
1062 }
1063
1064 #[rstest]
1067 #[case::position_0("#----", 0, 100)]
1068 #[case::position_10("#----", 10, 100)]
1069 #[case::position_20("-#---", 20, 100)]
1070 #[case::position_30("-#---", 30, 100)]
1071 #[case::position_40("--#--", 40, 100)]
1072 #[case::position_50("--#--", 50, 100)]
1073 #[case::position_60("---#-", 60, 100)]
1074 #[case::position_70("---#-", 70, 100)]
1075 #[case::position_80("----#", 80, 100)]
1076 #[case::position_90("----#", 90, 100)]
1077 #[case::position_one_out_of_bounds("----#", 100, 100)]
1078 fn thumb_visible_on_very_small_track(
1079 #[case] expected: &str,
1080 #[case] position: usize,
1081 #[case] content_length: usize,
1082 scrollbar_no_arrows: Scrollbar,
1083 ) {
1084 let size = expected.width() as u16;
1085 let mut buffer = Buffer::empty(Rect::new(0, 0, size, 1));
1086 let mut state = ScrollbarState::new(content_length)
1087 .position(position)
1088 .viewport_content_length(2);
1089 scrollbar_no_arrows.render(buffer.area, &mut buffer, &mut state);
1090 assert_eq!(buffer, Buffer::with_lines([expected]));
1091 }
1092
1093 #[rstest]
1094 #[case::scrollbar_height_0(10, 0)]
1095 #[case::scrollbar_width_0(0, 10)]
1096 fn do_not_render_with_empty_area(#[case] width: u16, #[case] height: u16) {
1097 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
1098 .begin_symbol(Some("<"))
1099 .end_symbol(Some(">"))
1100 .track_symbol(Some("-"))
1101 .thumb_symbol("#");
1102 let zero_width_area = Rect::new(0, 0, width, height);
1103 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 10));
1104
1105 let mut state = ScrollbarState::new(10);
1106 scrollbar.render(zero_width_area, &mut buffer, &mut state);
1107 }
1108
1109 #[rstest]
1110 #[case::vertical_left(ScrollbarOrientation::VerticalLeft)]
1111 #[case::vertical_right(ScrollbarOrientation::VerticalRight)]
1112 #[case::horizontal_top(ScrollbarOrientation::HorizontalTop)]
1113 #[case::horizontal_bottom(ScrollbarOrientation::HorizontalBottom)]
1114 fn render_in_minimal_buffer(#[case] orientation: ScrollbarOrientation) {
1115 let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
1116 let scrollbar = Scrollbar::new(orientation);
1117 let mut state = ScrollbarState::new(10).position(5);
1118 scrollbar.render(buffer.area, &mut buffer, &mut state);
1120 assert_eq!(buffer, Buffer::with_lines([" "]));
1121 }
1122
1123 #[rstest]
1124 #[case::vertical_left(ScrollbarOrientation::VerticalLeft)]
1125 #[case::vertical_right(ScrollbarOrientation::VerticalRight)]
1126 #[case::horizontal_top(ScrollbarOrientation::HorizontalTop)]
1127 #[case::horizontal_bottom(ScrollbarOrientation::HorizontalBottom)]
1128 fn render_in_zero_size_buffer(#[case] orientation: ScrollbarOrientation) {
1129 let mut buffer = Buffer::empty(Rect::ZERO);
1130 let scrollbar = Scrollbar::new(orientation);
1131 let mut state = ScrollbarState::new(10).position(5);
1132 scrollbar.render(buffer.area, &mut buffer, &mut state);
1134 }
1135
1136 #[rstest]
1137 #[case::horizontal_width_eq_arrows(ScrollbarOrientation::HorizontalTop, Rect::new(0, 0, 2, 1))]
1138 #[case::horizontal_width_lt_arrows(ScrollbarOrientation::HorizontalTop, Rect::new(0, 0, 1, 1))]
1139 #[case::vertical_height_eq_arrows(ScrollbarOrientation::VerticalLeft, Rect::new(0, 0, 1, 2))]
1140 #[case::vertical_height_lt_arrows(ScrollbarOrientation::VerticalLeft, Rect::new(0, 0, 1, 1))]
1141 fn part_lengths_returns_zeros_when_track_len_is_zero(
1142 #[case] orientation: ScrollbarOrientation,
1143 #[case] area: Rect,
1144 ) {
1145 let scrollbar = Scrollbar::new(orientation)
1146 .begin_symbol(Some("<"))
1147 .end_symbol(Some(">"))
1148 .track_symbol(Some("-"))
1149 .thumb_symbol("#");
1150
1151 let state = ScrollbarState::new(10)
1152 .position(5)
1153 .viewport_content_length(2);
1154
1155 let (start, thumb_len, end) = scrollbar.part_lengths(area, &state);
1156 assert_eq!((start, thumb_len, end), (0, 0, 0));
1157 }
1158
1159 #[test]
1160 fn part_lengths_returns_zeros_when_area_dimension_is_zero_even_without_arrows() {
1161 let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalTop)
1162 .begin_symbol(None)
1163 .end_symbol(None)
1164 .track_symbol(Some("-"))
1165 .thumb_symbol("#");
1166
1167 let state = ScrollbarState::new(10)
1168 .position(3)
1169 .viewport_content_length(2);
1170
1171 let (start, thumb_len, end) = scrollbar.part_lengths(Rect::new(0, 0, 0, 1), &state);
1172 assert_eq!((start, thumb_len, end), (0, 0, 0));
1173 }
1174
1175 #[test]
1181 fn thumb_stays_within_track_for_large_thumb_at_end() {
1182 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
1183 let area = Rect::new(0, 0, 1, 24);
1185 let state = ScrollbarState::new(9).position(8);
1186
1187 let (start, thumb_len, end) = scrollbar.part_lengths(area, &state);
1188
1189 assert!(
1190 start + thumb_len <= 22,
1191 "thumb overruns the track: start={start} + thumb_len={thumb_len} > 22"
1192 );
1193 assert_eq!(
1194 start + thumb_len + end,
1195 22,
1196 "parts must sum to the track length"
1197 );
1198 }
1199
1200 #[rstest]
1203 #[case::large_thumb_at_end("<-----#################>", 8, 9)]
1204 fn render_scrollbar_keeps_end_symbol_for_large_thumb(
1205 #[case] expected: &str,
1206 #[case] position: usize,
1207 #[case] content_length: usize,
1208 ) {
1209 let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalTop)
1210 .begin_symbol(Some("<"))
1211 .end_symbol(Some(">"))
1212 .track_symbol(Some("-"))
1213 .thumb_symbol("#");
1214 let mut buffer = Buffer::empty(Rect::new(0, 0, expected.width() as u16, 1));
1215 let mut state = ScrollbarState::new(content_length).position(position);
1216 scrollbar.render(buffer.area, &mut buffer, &mut state);
1217 assert_eq!(buffer, Buffer::with_lines([expected]));
1218 }
1219}